diff --git a/Cargo.lock b/Cargo.lock index f70399d2..70910ff1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4449,6 +4449,7 @@ dependencies = [ "vectorcraft-pathops", "vectorcraft-plugins", "vectorcraft-text", + "vectorcraft-three-d", ] [[package]] @@ -4704,6 +4705,13 @@ dependencies = [ "write-fonts", ] +[[package]] +name = "vectorcraft-three-d" +version = "0.8.0" +dependencies = [ + "vectorcraft-geom", +] + [[package]] name = "vectorcraft-tools" version = "0.8.0" @@ -4758,6 +4766,7 @@ dependencies = [ "vectorcraft-svg", "vectorcraft-testkit", "vectorcraft-text", + "vectorcraft-three-d", "vectorcraft-tools", "wat", ] diff --git a/Cargo.toml b/Cargo.toml index 3e9551a7..975b0e8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ vectorcraft-brush = { path = "crates/brush" } vectorcraft-trace = { path = "crates/trace" } vectorcraft-text = { path = "crates/text" } vectorcraft-effects = { path = "crates/effects" } +vectorcraft-three-d = { path = "crates/three-d" } vectorcraft-render = { path = "crates/render" } vectorcraft-svg = { path = "crates/svg" } vectorcraft-pdf = { path = "crates/pdf" } diff --git a/README.md b/README.md index 79b26dbb..0c27509c 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,8 @@ The scores are self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them. **What's missing:** -- 3D and Materials; + +- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); - the Photoshop-style raster effects (Effect Gallery); - CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface); - scripting, and the rest of Variables (image and graph kinds, dataset import); @@ -229,7 +230,7 @@ self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-0 advanced type, then hardening and packaging for 1.0. The prioritized list is in [Where we're lacking](ROADMAP.md#where-were-lacking-in-priority-order). -**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}` +**Workspace:** `crates/{geom, color, doc, pathops, text, effects, three-d, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}` and `apps/{vectorcraft, vectorcraft-cli, vectorcraft-web}`. The egui frontend is its own crate, so the UI can be swapped without touching the engine. diff --git a/ROADMAP.md b/ROADMAP.md index 282d06cc..19ba2188 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), 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), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675), Package collecting what placed documents link to (#777), area type, threaded type and type on a path made whole from Illustrator files' text documents (part of #637), the missing-fonts dialog with Find Fonts and a folder search (#792), Effect › Stylize › Glowing Edges (#765), Offset Path's Joins and Zig Zag's Points as dropdowns and Relative/Absolute as radio buttons in the effect dialogs (#828), the control channel's `app.quit` reporting a pending save prompt (#830), blurred type that stays put while the view moves (#755), Windows starting on Direct3D 12 and loading no Vulkan driver unless asked (#806), the Artboard tool naming its artboard once (#824), Save for Web taking typed pixel sizes (#839), rulers ticked over their whole length in a rotated view (#826), the gradient stop popover opening beside the art (#862) and recent colours recolouring the selected stop (#835), every align button following Align To (#789), the Properties panel's Paragraph section with all seven alignments and Hyphenate (#775), isolation mode dimming and locking the art around the isolated group (#833), color books (`.acb`) opening as swatch libraries (#831), files that can't be opened reported in a dialog (#861), `run` and `command_batch` steps using earlier steps' results (#785), ruler guides pulling drawn points with Smart Guides on and Smart Guides labelling centres and anchors on hover (#812), shadows and glows keeping their blend modes in PDF (#827) and `.ai` import skipping layer checks that can't succeed (#758), the Curvature tool editing any selected path (#798), raster exports composited in floating point so translucent edges over opaque art stay opaque (#787), Calligraphic and Bristle Brush Options with pen-pressure Calligraphic brushes (part of #852), a polygon's Polygon Properties in the Properties and Transform panels, Flare Tool Options that last and more tools opening their options on a double-click (part of #812), MCP file access confined with --automation-read-root / --automation-write-root (#832), the Transform effect's dialog with the reference point, Random, sliders and an angle dial (part of #885), artboard X/Y edits in the Properties and Transform panels and Artboard Options taking their art along with Move Artwork with Artboard (#922), Effect › Pixelate's Color Halftone, Crystallize, Mezzotint and Pointillize (part of gap 2), linked files changed while the app was in the background following Update Links when its window comes back to the front (#925), the Tabs panel's Leader and Align On fields keeping what is typed until Enter (#924), the Properties panel's Gradient section with the gradient's type and a freeform gradient's Draw (Points or Lines) while the Gradient tool is active (#921), PSD and PSB files opened and placed, linked or embedded, as their merged image (#918), Direct Selection and Group Selection marquees selecting the type, images and other objects without anchors that they touch (#927), PDF and `.ai` glyphs that no mapping names found by their outline in the installed font, so their text stays one type object (#811), Effect › Video's De-Interlace and NTSC Colors (part of gap 2), the Libraries panel's search, groups and library export/import (#926), Live Corner widgets on every selected path, dragged together (#938), Scatter, Art and Pattern Brush Options with Colorization and Scatter values on pen pressure (part of gap 5), artboard names fitting their artboard's width on screen when zoomed out (#949), Effect › Texture's Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (part of gap 2), Inner Glow's Center/Edge Source as a dropdown (#962), the macOS window buttons centred on the app bar (#968), a compact 32-point app bar starting at File (File › Exit quits) with a search icon, and User Interface › System Title Bar on Windows and Linux (#969), the German interface (#934), Graph Type's value axis on the left, right or both sides with separate scales (#971), Graph Type's tick marks per axis, category tick marks and value-axis label prefixes and suffixes (#974), Effect › Distort's Diffuse Glow, Glass and Ocean Ripple (part of gap 2), Variables (data merge): text and visibility variables, data sets and the Variables panel (#761), a window whose first frame never reaches the screen starting again on the next graphics processor (#964); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), 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), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675), Package collecting what placed documents link to (#777), area type, threaded type and type on a path made whole from Illustrator files' text documents (part of #637), the missing-fonts dialog with Find Fonts and a folder search (#792), Effect › Stylize › Glowing Edges (#765), Offset Path's Joins and Zig Zag's Points as dropdowns and Relative/Absolute as radio buttons in the effect dialogs (#828), the control channel's `app.quit` reporting a pending save prompt (#830), blurred type that stays put while the view moves (#755), Windows starting on Direct3D 12 and loading no Vulkan driver unless asked (#806), the Artboard tool naming its artboard once (#824), Save for Web taking typed pixel sizes (#839), rulers ticked over their whole length in a rotated view (#826), the gradient stop popover opening beside the art (#862) and recent colours recolouring the selected stop (#835), every align button following Align To (#789), the Properties panel's Paragraph section with all seven alignments and Hyphenate (#775), isolation mode dimming and locking the art around the isolated group (#833), color books (`.acb`) opening as swatch libraries (#831), files that can't be opened reported in a dialog (#861), `run` and `command_batch` steps using earlier steps' results (#785), ruler guides pulling drawn points with Smart Guides on and Smart Guides labelling centres and anchors on hover (#812), shadows and glows keeping their blend modes in PDF (#827) and `.ai` import skipping layer checks that can't succeed (#758), the Curvature tool editing any selected path (#798), raster exports composited in floating point so translucent edges over opaque art stay opaque (#787), Calligraphic and Bristle Brush Options with pen-pressure Calligraphic brushes (part of #852), a polygon's Polygon Properties in the Properties and Transform panels, Flare Tool Options that last and more tools opening their options on a double-click (part of #812), MCP file access confined with --automation-read-root / --automation-write-root (#832), the Transform effect's dialog with the reference point, Random, sliders and an angle dial (part of #885), artboard X/Y edits in the Properties and Transform panels and Artboard Options taking their art along with Move Artwork with Artboard (#922), Effect › Pixelate's Color Halftone, Crystallize, Mezzotint and Pointillize (part of gap 2), linked files changed while the app was in the background following Update Links when its window comes back to the front (#925), the Tabs panel's Leader and Align On fields keeping what is typed until Enter (#924), the Properties panel's Gradient section with the gradient's type and a freeform gradient's Draw (Points or Lines) while the Gradient tool is active (#921), PSD and PSB files opened and placed, linked or embedded, as their merged image (#918), Direct Selection and Group Selection marquees selecting the type, images and other objects without anchors that they touch (#927), PDF and `.ai` glyphs that no mapping names found by their outline in the installed font, so their text stays one type object (#811), Effect › Video's De-Interlace and NTSC Colors (part of gap 2), the Libraries panel's search, groups and library export/import (#926), Live Corner widgets on every selected path, dragged together (#938), Scatter, Art and Pattern Brush Options with Colorization and Scatter values on pen pressure (part of gap 5), artboard names fitting their artboard's width on screen when zoomed out (#949), Effect › Texture's Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (part of gap 2), Inner Glow's Center/Edge Source as a dropdown (#962), the macOS window buttons centred on the app bar (#968), a compact 32-point app bar starting at File (File › Exit quits) with a search icon, and User Interface › System Title Bar on Windows and Linux (#969), the German interface (#934), Graph Type's value axis on the left, right or both sides with separate scales (#971), Graph Type's tick marks per axis, category tick marks and value-axis label prefixes and suffixes (#974), Effect › Distort's Diffuse Glow, Glass and Ocean Ripple (part of gap 2), Variables (data merge): text and visibility variables, data sets and the Variables panel (#761), a window whose first frame never reaches the screen starting again on the next graphics processor (#964), Effect › 3D and Materials › Revolve as a live effect with rotation and light gizmos (#846, part of gap 3); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -35,7 +35,7 @@ since 2026-10-01. |---|---|---| | **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules | | **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) | -| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), scripting | +| **Large missing subsystems** | 0–20% | 3D & Materials (~8%, initial Revolve only), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), scripting | | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | @@ -63,9 +63,9 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent Brick, Burlap, Canvas and Sandstone surfaces made in code; Load Texture is missing) and Video › De-Interlace and NTSC Colors; each new filter is now one function, a catalogue row, menu and translations. Parallelizes well across agents. 19–31 h. -3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software - renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors. - The largest single gap. 50–80 h. +3. **3D and Materials:** initial Revolve has an independent software geometry crate, editable profiles, + shaded vector output and basic lighting. Extrude & Bevel, Inflate, Rotate, materials, caps and more complete + visibility remain. The subsystem is still an early implementation. 45–75 h. 4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, proportional vertical metrics, manual tate-chu-yoko), Optical Margin Alignment, the hyphenation and justification options (the Every-line/Single-line composer choice is done), tab leaders, a @@ -155,6 +155,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. - **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). - **Geometry and effects:** + - Initial live Revolve (#605): a portable geometry and shading crate, editable path/compound profiles, compact options with artboard rotation and light gizmos, numeric scrubbing/sliders/reset, command access, SVG/PDF vector output and Expand Appearance. A Revolve-specific expansion option trims covered opaque solid surfaces while preserving visible interiors. Open ends are uncapped; shading is flat and intersecting-surface visibility approximate. - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). - **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). @@ -200,7 +201,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | -| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | +| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; graph Design/Column/Marker designs pending | 6–10 | @@ -232,7 +233,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize), Smart Blur's Edge Only / Overlay Edge modes, Glass's and Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 19–31 | -| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | +| 3D and Materials | 4 | ~8% | Initial Revolve only, with simple lighting and Revolve-specific visible-surface expansion; Extrude & Bevel, Inflate, Rotate, caps, materials, shadows and more complete visibility remain | 45–75 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 | | Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 | diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index aad25ac9..49b8e297 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] vectorcraft-geom = { workspace = true } +vectorcraft-three-d = { workspace = true } vectorcraft-doc = { workspace = true } vectorcraft-pathops = { workspace = true } vectorcraft-text = { workspace = true } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index f1a08bee..148f2c01 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -39,7 +39,8 @@ fn clear_item_effects(item: &mut AppearanceItem) { /// Does anything in `n`'s subtree need baking? pub fn needs_bake(n: &Node) -> bool { - has_adjustment(n) + crate::has_revolve(n) + || has_adjustment(n) || has_crop_marks(n) || has_pathfinder(n) || has_own_paint(n) @@ -92,6 +93,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { if let Some(m) = crop_marks_art(n) { return Some(bake_pieces(d, m)); } + if let Some(m) = crate::revolve_art(n) { + let m = evaluate_container(&m).unwrap_or(m); + return Some(bake_pieces(d, m)); + } // Type, images, symbol instances and live objects: reshaped through their outlines. if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) { let symbol = match &n.kind { @@ -226,6 +231,10 @@ pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, & /// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and /// effects are dropped. `None` for other kinds of objects. pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option { + if let Some(m) = crate::revolve::expand_revolve_art(n) { + let m = evaluate_container(&m).unwrap_or(m); + return Some(bake_pieces(d, m)); + } let (g, rule, ctx, mut m) = leaf_base(n)?; m.appearance.effects.retain(|e| e.visible); let mut pieces = vec![]; @@ -289,6 +298,9 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) { /// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None` /// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing. pub fn bake_appearance(n: &Node) -> Option { + if let Some(m) = crate::revolve_art(n) { + return Some(m); + } if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) { return None; } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 1a32b427..1814ef08 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -42,6 +42,7 @@ mod marks; pub mod pixel; mod raster; mod reshape; +mod revolve; pub mod stroke; mod stylize; mod util; @@ -68,6 +69,7 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RasterFx, outset, raster_effects}; pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape}; +pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use warp::{WarpStyle, warp_point}; /// Catalogue entry for one effect. @@ -122,6 +124,7 @@ fn lengths_of(id: &str) -> Lengths { "distort.tweak" => Lengths { always: &[], absolute: &["h", "v"] }, "distort.transform" => always(&["moveH", "moveV"]), "path.offsetPath" => always(&["offset"]), + "threeD.revolve" => always(&["offset"]), "path.outlineStroke" => always(&["width"]), "stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]), "stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]), @@ -179,6 +182,13 @@ pub fn effect_catalog() -> Vec { let g = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: false, lengths: lengths_of(id) }; let r = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: true, lengths: lengths_of(id) }; let mut v = vec![ + g( + REVOLVE, + "Revolve…", + &["Effect", "3D and Materials"], + "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspective: % (0..100, 0), segments: integer (8..128, 64), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.", + json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspective":0.0,"segments":64,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0,"expandVisibleOnly":true}), + ), g( "convertToShape.rectangle", "Rectangle…", @@ -589,6 +599,7 @@ pub fn is_geometry(id: &str) -> bool { && !is_pathfinder(id) && !is_adjustment(id) && id != CROP_MARKS + && id != REVOLVE && (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some()) } diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs new file mode 100644 index 00000000..2d5fa3eb --- /dev/null +++ b/crates/effects/src/revolve.rs @@ -0,0 +1,319 @@ +//! Revolve as live, shaded vector art, shared by the canvas and every exporter. +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer}; +use vectorcraft_geom::{PathData, Rect}; +use vectorcraft_three_d::{Edge, Revolve}; + +use crate::util::{flag, num, text}; +use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; + +pub const REVOLVE: &str = "threeD.revolve"; + +fn visible(fx: &[Effect]) -> bool { + fx.iter().any(|e| e.visible && e.id == REVOLVE) +} + +pub fn has_revolve(n: &Node) -> bool { + visible(&n.appearance.effects) || n.appearance.items.iter().any(|i| i.visible() && visible(i.effects())) +} + +/// Shared document-parameter conversion for rendering and interactive preview guides. +pub fn revolve_options(p: &Value) -> Revolve { + Revolve { + angle: num(p, "angle", 360.0), + offset: num(p, "offset", 0.0), + edge: if text(p, "edge", "left") == "right" { Edge::Right } else { Edge::Left }, + rotation_x: num(p, "rotationX", 0.0), + rotation_y: num(p, "rotationY", 0.0), + rotation_z: num(p, "rotationZ", 0.0), + perspective: num(p, "perspective", 0.0) / 100.0, + segments: num(p, "segments", 64.0).clamp(8.0, 128.0) as usize, + light_azimuth: num(p, "lightAzimuth", -45.0), + light_elevation: num(p, "lightElevation", 45.0), + light_intensity: num(p, "lightIntensity", 80.0) / 100.0, + ambient: num(p, "ambient", 25.0) / 100.0, + shade: flag(p, "shade", true), + } +} + +/// Validate before a command mutates the document. Loading external files is still bounded +/// by the evaluator; unsupported source kinds remain unchanged when rendered. +pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { + let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?; + let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?; + if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) { + return Err("Revolve requires at least two anchors".into()); + } + if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) { + return Err("Revolve edge must be left or right".into()); + } + vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ()) +} + +fn profile(n: &Node) -> Option { + match &n.kind { + NodeKind::Path { path, guide: false, clipping: false, .. } => Some(path.clone()), + NodeKind::Compound { children, .. } => { + Some(PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect())) + } + _ => None, + } +} + +fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { + apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx) +} + +#[allow(clippy::too_many_arguments)] +fn surface( + n: &Node, + path: &PathData, + fx: &Effect, + paint: Paint, + opacity: f32, + blend: vectorcraft_doc::color::BlendMode, + expand: bool, +) -> Option { + let b = path.bounds()?; + // A loaded file or a profile edited past the limits keeps drawing its source path instead + // of crashing the frame. Commands validate the same evaluator and report its errors. + let faces = vectorcraft_three_d::revolve(path, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?; + // Visibility is an expansion option, never a change to live geometry or export baking. + // Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility + // failure also retains the complete surface rather than losing any of the user's art. + let visible = (expand + && flag(&fx.params, "expandVisibleOnly", true) + && paint.color().is_some() + && opacity == 1.0 + && blend == Default::default() + && n.has_default_transparency()) + .then(|| vectorcraft_three_d::visible_faces(&faces).ok()) + .flatten(); + let faces = visible.unwrap_or_else(|| { + faces.into_iter().map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth }).collect() + }); + let children = faces + .into_iter() + .map(|face| { + let mut fill = FillLayer::new(paint.clone()); + let mut items = vec![]; + if let Some(c) = paint.color() { + let [r, g, b] = c.to_rgb(); + let s = face.brightness as f32; + fill.paint = Paint::solid(Color::rgb(r * s, g * s, b * s)); + items.push(AppearanceItem::Fill(fill)); + } else { + items.push(AppearanceItem::Fill(fill)); + let mut shadow = FillLayer::new(Paint::solid(Color::BLACK)); + shadow.opacity = (1.0 - face.brightness) as f32; + items.push(AppearanceItem::Fill(shadow)); + } + // Independently antialiased adjacent polygons otherwise leave a hairline grid. + // Matching narrow edge paint overlaps their coverage in the canvas and exporters. + // Keep opacity on the surface group so these edges never accumulate transparency. + let items = items + .into_iter() + .flat_map(|item| { + let AppearanceItem::Fill(fill) = &item else { return vec![item] }; + let mut edge = StrokeLayer::new(fill.paint.clone(), 0.75); + edge.opacity = fill.opacity; + vec![item, AppearanceItem::Stroke(edge)] + }) + .collect(); + Arc::new(Node::path(n.id, face.path, Appearance { items, ..Default::default() })) + }) + .collect(); + let mut group = Node::group(n.id, children); + group.opacity = opacity; + group.blend = blend; + Some(group) +} + +fn paint_of(item: &AppearanceItem) -> (Paint, f32, vectorcraft_doc::color::BlendMode) { + match item { + AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend), + AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity, s.blend), + } +} + +/// Evaluated art keeps the source's identity and transparency. Each item's opacity belongs +/// to its entire surface, not to each face, so overlapping faces don't accumulate alpha. +pub fn revolve_art(n: &Node) -> Option { + art(n, false) +} + +/// Revolve's own Expand Appearance path. The shared command and UI remain unchanged. +pub(crate) fn expand_revolve_art(n: &Node) -> Option { + art(n, true) +} + +fn art(n: &Node, expand: bool) -> Option { + if !has_revolve(n) { + return None; + } + let path = profile(n)?; + let ctx = GeomContext::of(n); + let mut children = vec![]; + let mut remaining = n.appearance.effects.clone(); + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { + let fx = n.appearance.effects.get(index)?; + let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); + // An object's surface uses its top visible fill, falling back to its stroke. + let item = n + .appearance + .items + .iter() + .rev() + .find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i)) + .or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i)))?; + let (paint, opacity, blend) = paint_of(item); + let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); + children.push(Arc::new(surface(n, &path, fx, paint, opacity, blend, expand)?)); + // Later geometry acts on the projected art as a whole through container evaluation. + remaining = n + .appearance + .effects + .iter() + .enumerate() + .filter(|(i, e)| e.id != REVOLVE && (!is_geometry(&e.id) || *i > index)) + .map(|(_, e)| e.clone()) + .collect(); + } else { + let path = geometry(&path, &n.appearance.effects, &ctx); + remaining.retain(|e| !is_geometry(&e.id)); + for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { + if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) { + let fx = item.effects().get(index)?; + let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); + let (paint, opacity, blend) = paint_of(item); + let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); + let mut art = surface(n, &p, fx, paint, opacity, blend, expand)?; + art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect(); + children.push(Arc::new(art)); + } else { + children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() }))); + } + } + } + let mut art = n.clone(); + art.kind = NodeKind::Group { children, clip: false }; + art.appearance = Appearance { effects: remaining, ..Default::default() }; + Some(art) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use vectorcraft_doc::{Document, NodeId}; + use vectorcraft_geom::{Affine, BezPath}; + fn profile_node() -> Node { + let mut b = BezPath::new(); + b.move_to((100.0, 40.0)); + b.line_to((130.0, 60.0)); + b.line_to((130.0, 140.0)); + b.line_to((100.0, 160.0)); + Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0)) + } + fn fx() -> Effect { + crate::new_effect(REVOLVE, &json!({})).unwrap() + } + #[test] + fn source_stays_editable_surface_moves_and_hidden_effect_restores_it() { + let mut n = profile_node(); + let before = n.path_data().unwrap().clone(); + n.appearance.effects.push(fx()); + let a = revolve_art(&n).unwrap(); + assert!(!a.children().unwrap().is_empty()); + assert_eq!(n.path_data().unwrap(), &before); + let b = a.visual_bounds().unwrap(); + n.transform(Affine::translate((80.0, 20.0)), false); + let moved = revolve_art(&n).unwrap().visual_bounds().unwrap(); + assert!((moved.x0 - b.x0 - 80.0).abs() < 1e-6 && (moved.y0 - b.y0 - 20.0).abs() < 1e-6); + n.appearance.effects[0].visible = false; + assert!(revolve_art(&n).is_none()); + } + #[test] + fn item_effect_and_transparency_are_scoped_to_whole_surface() { + let mut n = profile_node(); + n.opacity = 0.6; + n.appearance.items[0].effects_mut().push(fx()); + if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] { + f.opacity = 0.4; + } + let a = revolve_art(&n).unwrap(); + assert_eq!(a.opacity, 0.6); + let surface = &a.children().unwrap()[0]; + assert_eq!(surface.opacity, 0.4); + assert!(surface.children().unwrap().iter().all(|c| c.opacity == 1.0 && !has_revolve(c))); + } + #[test] + fn export_bakes_faces_with_unique_ids_and_no_revolve_effects() { + let mut d = Document::new(300.0, 300.0); + let mut n = profile_node(); + n.id = d.alloc_id(); + n.appearance.effects.push(fx()); + let mut stroke = |_: &mut Document, _: &PathData, _: vectorcraft_geom::FillRule, _: &vectorcraft_doc::StrokeLayer| None; + let art = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap(); + let mut ids = std::collections::HashSet::new(); + fn visit(n: &Node, ids: &mut std::collections::HashSet) { + assert!(ids.insert(n.id)); + assert!(!has_revolve(n)); + for c in n.children().into_iter().flatten() { + visit(c, ids); + } + } + visit(&art, &mut ids); + assert!(ids.len() > 50); + } + + #[test] + fn visible_surface_option_only_affects_expansion_and_can_keep_all_faces() { + let mut n = profile_node(); + n.appearance.effects.push(fx()); + let source = n.path_data().unwrap().clone(); + let live = revolve_art(&n).unwrap(); + let expanded = expand_revolve_art(&n).unwrap(); + let count = |n: &Node| n.children().unwrap()[0].children().unwrap().len(); + assert!(count(&expanded) < count(&live) * 3 / 4, "hidden back surfaces are removed"); + assert_eq!(crate::bake_appearance(&n), Some(live.clone()), "other baking retains the full surface"); + n.appearance.effects[0].params["expandVisibleOnly"] = json!(false); + assert_eq!(revolve_art(&n), Some(live.clone()), "the live view ignores the expansion setting"); + assert_eq!(expand_revolve_art(&n), Some(live)); + assert_eq!(n.path_data(), Some(&source)); + n.appearance.effects[0].params["expandVisibleOnly"] = json!(true); + assert_eq!(expand_revolve_art(&n), Some(expanded.clone())); + // Per-item Revolve uses the same option, without trimming another item's artwork. + let effect = n.appearance.effects.remove(0); + n.appearance.items[0].effects_mut().push(effect); + assert_eq!(expand_revolve_art(&n).unwrap().children().unwrap()[0], expanded.children().unwrap()[0]); + } + + #[test] + fn transparency_unknown_paints_and_later_distortion_keep_complete_surfaces() { + let mut n = profile_node(); + n.appearance.effects.push(fx()); + for change in 0..4 { + let mut sample = n.clone(); + match change { + 0 => sample.opacity = 0.5, + 1 => { + if let AppearanceItem::Fill(f) = &mut sample.appearance.items[0] { + f.opacity = 0.4; + } + } + 2 => sample.blend = vectorcraft_doc::color::BlendMode::Multiply, + _ => sample.appearance.effects.push(crate::new_effect("distort.twist", &json!({"angle":45})).unwrap()), + } + assert_eq!(expand_revolve_art(&sample), revolve_art(&sample), "preserve surface for case {change}"); + } + let mut sample = n; + if let AppearanceItem::Fill(f) = &mut sample.appearance.items[0] { + f.paint = Paint::Pattern { pattern: "unknown transparency".into(), xf: Affine::IDENTITY }; + } + assert_eq!(expand_revolve_art(&sample), revolve_art(&sample)); + } +} diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 1d3c66a7..328ffa05 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -107,6 +107,7 @@ fn edit_effects( let Some(n) = d.node_mut(*id) else { continue }; let index = item.effects_item(&n.appearance, cmd)?; if n.appearance.effects_mut(index).is_some_and(&mut f) { + check_revolve_count(n, cmd)?; done.push(*id); } } @@ -117,6 +118,14 @@ fn edit_effects( }) } +fn check_revolve_count(n: &Node, cmd: &str) -> Result<()> { + let count = n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects())).filter(|e| e.id == effects::REVOLVE).count(); + if count > 1 { + return Err(bad(cmd, "A profile supports one Revolve effect. Edit the existing effect's options instead.")); + } + Ok(()) +} + pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.apply"; let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?; @@ -140,6 +149,15 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true })); } let item = item_target(s, p, C)?; + if id == effects::REVOLVE { + for id in appearance_targets(s, p)? { + let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; + effects::validate_revolve(n, &effect.params).map_err(|e| bad(C, e))?; + if effects::has_revolve(n) { + return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead.")); + } + } + } let mut index = 0; let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| { fx.push(effect.clone()); @@ -237,6 +255,7 @@ fn move_effect(s: &mut Session, p: &Value) -> Result { let Some(fx) = n.appearance.effects_mut(di) else { continue }; let at = to.min(fx.len()); fx.insert(at, e); + check_revolve_count(n, C)?; landed.get_or_insert((at, di)); done.push(*id); } @@ -258,6 +277,20 @@ fn set_params(s: &mut Session, p: &Value) -> Result { } let visible = p.get("visible").and_then(Value::as_bool); let item = item_target(s, p, C)?; + for id in appearance_targets(s, p)? { + let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; + let target = item.effects_item(&n.appearance, C)?; + if let Some(e) = n.appearance.effects_at(target).and_then(|fx| fx.get(index)) + && e.id == effects::REVOLVE + && visible != Some(false) + { + let mut prospective = effects::merged_params(&e.id, &e.params); + if let (Some(cur), Some(new)) = (prospective.as_object_mut(), params.as_object()) { + cur.extend(new.clone()); + } + effects::validate_revolve(n, &prospective).map_err(|e| bad(C, e))?; + } + } let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| { let Some(e) = fx.get_mut(index) else { return false }; if let (Value::Object(new), cur) = (¶ms, &mut e.params) { @@ -454,6 +487,7 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { mod tests { use serde_json::json; use vectorcraft_doc::NodeKind; + use vectorcraft_render::effects; use crate::{NodeId, Session}; @@ -468,6 +502,71 @@ mod tests { s.doc().unwrap().doc.node(id).cloned().unwrap() } + #[test] + fn revolve_commands_keep_profile_live_edit_options_expand_and_undo() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); + let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let source = node(&s, id).path_data().unwrap().clone(); + s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap(); + let before = effects_for_test(&s, id); + assert_eq!(node(&s, id).path_data(), Some(&source)); + let doc = &s.doc().unwrap().doc; + let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + assert_eq!(restored.node(id).unwrap().path_data(), Some(&source)); + assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), before); + let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap(); + assert!(bytes.starts_with(b"%PDF")); + assert!(s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).is_err()); + assert!(s.execute("effect.duplicate", &json!({"index":0,"ids":[id.0],"item":null})).is_err()); + s.execute("effect.setParams", &json!({"index":0,"params":{"angle":180,"offset":10},"ids":[id.0],"item":null})).unwrap(); + assert_ne!(effects_for_test(&s, id), before); + assert!(s.execute("effect.setParams", &json!({"index":0,"params":{"edge":"invalid"},"ids":[id.0],"item":null})).is_err()); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + assert!(matches!(node(&s, id).kind, NodeKind::Group { .. })); + assert!(!effects::has_revolve(&node(&s, id))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).path_data(), Some(&source)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(effects_for_test(&s, id), before); + } + + fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node { + effects::revolve_art(&node(s, id)).unwrap() + } + + #[test] + fn revolve_visibility_parameter_saves_expands_and_undoes_without_other_appearance_changes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); + let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap(); + let live = node(&s, id); + let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["expandVisibleOnly"], json!(true)); + let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len(); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + let visible = count(&node(&s, id)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), live); + s.execute("effect.setParams", &json!({"index":0,"item":null,"ids":[id.0],"params":{"expandVisibleOnly":false}})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + assert!(count(&node(&s, id)) > visible); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).appearance.effects[0].params["expandVisibleOnly"], json!(false)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), live); + // The shared command still expands ordinary multiple-fill artwork as before. + let r = s.execute("shape.rectangle", &json!({"x":10,"y":10,"width":20,"height":20})).unwrap(); + let ordinary = NodeId(r["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect":"distort.twist","ids":[ordinary.0],"params":{"angle":15}})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids":[ordinary.0]})).unwrap(); + assert!(node(&s, ordinary).appearance.effects.is_empty()); + } + #[test] fn apply_uses_catalog_defaults_and_undoes() { let (mut s, id) = session_with_rect(); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 38bc06be..266bfe0b 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -61,6 +61,7 @@ pub(crate) fn has_object_fx(n: &Node) -> bool { || effects::has_container_appearance(n) || effects::has_crop_marks(n) || effects::has_adjustment(n) + || effects::has_revolve(n) } /// Is `n` a group or layer (whose evaluated art keeps its knockout setting)? @@ -123,6 +124,12 @@ fn item_effects(item: &AppearanceItem) -> &[Effect] { /// Visual bounds including geometry effects, stroke outsets and shadows/glows. pub(crate) fn visual_bounds(n: &Node) -> Option { + if let Some(art) = effects::revolve_art(n) { + return cull_bounds(&art).map(|b| { + let o = effects::outset(&n.appearance.effects, b); + b.inflate(o, o) + }); + } let bp = node_bezpath(n)?; let ctx = GeomContext::of(n); let g = effected_path(n, &bp); @@ -408,12 +415,13 @@ impl Renderer { _ => None, }; let container = is_container(a); - let art = match effects::crop_marks_art(a).or_else(|| effects::reshape(a, symbol.as_ref())) { + let art = match effects::crop_marks_art(a).or_else(|| effects::revolve_art(a)).or_else(|| effects::reshape(a, symbol.as_ref())) { Some(r) => r, None if container => effects::evaluate_container(a).unwrap_or_else(|| (**a).clone()), None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::Path { .. } | NodeKind::Compound { .. }) => (**a).clone(), None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), }; + let art = if effects::has_revolve(a) { effects::evaluate_container(&art).unwrap_or(art) } else { art }; let mut art = self.adjusted_art(doc, art, effects::color_map(&a.appearance.effects).as_ref()); // A path's geometry effects still apply when it is drawn. let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests_objectfx.rs index 1026829e..9041b107 100644 --- a/crates/render/src/tests_objectfx.rs +++ b/crates/render/src/tests_objectfx.rs @@ -40,6 +40,69 @@ fn lum(p: [u8; 4]) -> u32 { const WHITE: u32 = 765; +#[test] +fn revolve_canvas_matches_baked_art_and_shades_its_surface() { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to((50.0, 15.0)); + bp.line_to((75.0, 30.0)); + bp.line_to((75.0, 75.0)); + bp.line_to((50.0, 90.0)); + let mut n = Node::path( + NodeId(0), + vectorcraft_geom::PathData::from_bezpath(&bp), + Appearance::basic(Paint::solid(Color::rgb(0.9, 0.2, 0.1)), Paint::None, 0.0), + ); + n.appearance.effects.push(fx("threeD.revolve", json!({"rotationX":0,"rotationY":0}))); + let d = doc_with(Document::new(100.0, 100.0), n); + let live = render(&d); + let baked = vectorcraft_effects::bake_document(&d).unwrap(); + let exported = render(&baked); + assert_eq!(live.pixels, exported.pixels, "canvas and baked/exported faces agree"); + let reds: Vec<_> = + live.pixels.as_chunks::<4>().0.iter().filter(|p| p[0] as u16 > p[1] as u16 * 2 && p[0] as u16 > p[2] as u16 * 2).map(|p| p[0]).collect(); + assert!(reds.len() > 1500, "a filled surface, rather than the open source path"); + assert!(reds.iter().max().unwrap() - reds.iter().min().unwrap() > 80, "lighting varies across the surface"); + assert_eq!(live.pixels, render_threads(&d, 4).pixels); +} + +#[test] +fn revolve_visible_surface_expansion_preserves_the_view_and_transparent_art() { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to((50.0, 15.0)); + bp.curve_to((80.0, 25.0), (80.0, 75.0), (50.0, 90.0)); + let mut n = Node::path( + NodeId(0), + vectorcraft_geom::PathData::from_bezpath(&bp), + Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0), + ); + for (angle, rotation, perspective, opacity) in + [(360.0, 0.0, 0.0, 1.0), (360.0, -35.0, 55.0, 1.0), (150.0, -28.0, 40.0, 1.0), (360.0, -35.0, 55.0, 0.5)] + { + n.opacity = opacity; + n.appearance.effects = vec![fx( + "threeD.revolve", + json!({"angle":angle,"rotationX":rotation,"rotationY":25,"perspective":perspective,"segments":32,"expandVisibleOnly":true}), + )]; + let d = doc_with(Document::new(100.0, 100.0), n.clone()); + let live = render(&d); + let mut expanded = d.clone(); + let id = expanded.layers[0].children().unwrap()[0].id; + let source = expanded.node(id).unwrap().clone(); + let mut stroke = |_: &mut Document, _: &vectorcraft_geom::PathData, _: vectorcraft_geom::FillRule, _: &vectorcraft_doc::StrokeLayer| None; + let art = vectorcraft_effects::expand_leaf(&mut expanded, &source, &mut stroke).unwrap(); + *expanded.node_mut(id).unwrap() = art; + let flat = render(&expanded); + let difference: u64 = live.pixels.iter().zip(&flat.pixels).map(|(a, b)| a.abs_diff(*b) as u64).sum(); + let average = difference as f64 / live.pixels.len() as f64; + assert!(average < 1.0, "appearance differs by {average} channel levels: angle {angle}, rotation {rotation}, opacity {opacity}"); + if opacity < 1.0 { + assert_eq!(live.pixels, flat.pixels); + } + // Export baking keeps every face, even when the expansion checkbox is checked. + assert_eq!(live.pixels, render(&vectorcraft_effects::bake_document(&d).unwrap()).pixels); + } +} + /// A 30×30 red image placed at (20, 20). fn image_doc(effects: Vec) -> Document { let mut d = Document::new(100.0, 100.0); diff --git a/crates/svg/tests/effects_export.rs b/crates/svg/tests/effects_export.rs index 5e6f433e..ea63c7fc 100644 --- a/crates/svg/tests/effects_export.rs +++ b/crates/svg/tests/effects_export.rs @@ -21,6 +21,23 @@ fn fx(id: &str) -> Effect { Effect { id: id.into(), params: json!({}), visible: true } } +#[test] +fn revolve_exports_shaded_vector_faces_and_keeps_the_source_live() { + let bp = vectorcraft_geom::BezPath::from_svg("M100 30 L130 50 L130 140 L100 160").unwrap(); + let mut n = Node::path( + NodeId(0), + vectorcraft_geom::PathData::from_bezpath(&bp), + Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0), + ); + n.appearance.effects.push(fx("threeD.revolve")); + let d = doc_with(n); + let before = d.clone(); + let svg = export(&d, &ExportOptions::default()); + assert!(svg.matches(" 50, "Revolve should export vector surfaces"); + assert!(!svg.contains(" Self { + Self(self.0 - b.0, self.1 - b.1, self.2 - b.2) + } + fn cross(self, b: Self) -> Self { + Self(self.1 * b.2 - self.2 * b.1, self.2 * b.0 - self.0 * b.2, self.0 * b.1 - self.1 * b.0) + } + fn dot(self, b: Self) -> f64 { + self.0 * b.0 + self.1 * b.1 + self.2 * b.2 + } + fn unit(self) -> Self { + let l = self.dot(self).sqrt(); + if l < 1e-12 { Self::default() } else { Self(self.0 / l, self.1 / l, self.2 / l) } + } +} + +/// The vertical axis is offset outward from the selected edge of the profile's bounds. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Edge { + #[default] + Left, + Right, +} + +/// All angles are degrees; offset is in document points, lighting values are 0..1. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Revolve { + pub angle: f64, + pub offset: f64, + pub edge: Edge, + pub rotation_x: f64, + pub rotation_y: f64, + pub rotation_z: f64, + /// Perspective strength, 0 for orthographic projection, up to 1. + pub perspective: f64, + /// Radial subdivisions for a full turn, clamped to 8..128. + pub segments: usize, + pub light_azimuth: f64, + pub light_elevation: f64, + pub light_intensity: f64, + pub ambient: f64, + pub shade: bool, +} + +impl Default for Revolve { + fn default() -> Self { + Self { + angle: 360.0, + offset: 0.0, + edge: Edge::Left, + rotation_x: 0.0, + rotation_y: 0.0, + rotation_z: 0.0, + perspective: 0.0, + segments: 64, + light_azimuth: -45.0, + light_elevation: 45.0, + light_intensity: 0.8, + ambient: 0.25, + shade: true, + } + } +} + +fn finite(v: f64, fallback: f64, min: f64, max: f64) -> f64 { + if v.is_finite() { v.clamp(min, max) } else { fallback } +} + +impl Revolve { + /// Unit bases of the rings that adjust the Euler X, Y and Z view parameters. + /// X turns before Y and Z, Y before Z, and Z turns in the screen plane. + /// Each pair has a cross product pointing along that parameter's rotation axis. + pub fn rotation_planes(self) -> [[[f64; 3]; 2]; 3] { + let o = self.sanitized(); + let x_frame = Self { rotation_x: 0.0, ..o }; + let y_frame = Self { rotation_x: 0.0, rotation_y: 0.0, ..o }; + [ + [x_frame.rotate(V3(0.0, 1.0, 0.0)), x_frame.rotate(V3(0.0, 0.0, 1.0))], + [y_frame.rotate(V3(1.0, 0.0, 0.0)), y_frame.rotate(V3(0.0, 0.0, -1.0))], + [V3(1.0, 0.0, 0.0), V3(0.0, 1.0, 0.0)], + ] + .map(|plane| plane.map(|v| [v.0, v.1, v.2])) + } + + /// The fixed centre of view rotation, in document coordinates. + pub fn rotation_origin(self, bounds: Rect) -> Point { + let o = self.sanitized(); + Point::new(if o.edge == Edge::Left { bounds.x0 - o.offset } else { bounds.x1 + o.offset }, bounds.center().y) + } + + /// Rotated unit axes for a view-orientation guide (screen Y points down). + pub fn view_axes(self) -> [Point; 3] { + [V3(1.0, 0.0, 0.0), V3(0.0, 1.0, 0.0), V3(0.0, 0.0, 1.0)].map(|v| { + let p = self.sanitized().rotate(v); + Point::new(p.0, p.1) + }) + } + + /// The same projected vertical axis used by the surface evaluator. + pub fn projected_axis(self, bounds: Rect) -> [Point; 2] { + let o = self.sanitized(); + let axis = match o.edge { + Edge::Left => bounds.x0 - o.offset, + Edge::Right => bounds.x1 + o.offset, + }; + let center_y = bounds.center().y; + let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs()); + let distance = 4.0 * radius.max(bounds.height().abs()).max(1.0); + [bounds.y0, bounds.y1].map(|y| { + let v = o.rotate(V3(0.0, y - center_y, 0.0)); + let factor = distance / (distance - v.2 * o.perspective); + Point::new(axis + v.0 * factor, center_y + v.1 * factor) + }) + } + + /// Directional light in view space, shared by shading and interactive guides. + pub fn light_direction(self) -> [f64; 3] { + let o = self.sanitized(); + let (sl, cl) = o.light_elevation.to_radians().sin_cos(); + let (sa, ca) = o.light_azimuth.to_radians().sin_cos(); + [cl * sa, -sl, cl * ca] + } + + /// Light-direction orbits as (centre, cosine basis, sine basis), in view space. + /// The first changes azimuth at the current elevation. The second changes elevation + /// at the current azimuth. The meridian's valid parameter range is -90..90 degrees. + pub fn light_orbits(self) -> [[[f64; 3]; 3]; 2] { + let o = self.sanitized(); + let (se, ce) = o.light_elevation.to_radians().sin_cos(); + let (sa, ca) = o.light_azimuth.to_radians().sin_cos(); + [[[0.0, -se, 0.0], [0.0, 0.0, ce], [ce, 0.0, 0.0]], [[0.0, 0.0, 0.0], [sa, 0.0, ca], [0.0, -1.0, 0.0]]] + } + + /// Move a light on its current hemisphere, preserving the azimuth at the poles. + /// A light behind the object stays behind when its projected handle is dragged. + pub fn light_at_on_side(self, point: Point) -> [f64; 2] { + let o = self.sanitized(); + let point = Point::new(finite(point.x, 0.0, -1e8, 1e8), finite(point.y, 0.0, -1e8, 1e8)); + let length = point.x.hypot(point.y).max(1.0); + let (x, y) = (point.x / length, point.y / length); + let z = (1.0 - x * x - y * y).max(0.0).sqrt().copysign(o.light_direction()[2]); + let azimuth = if x.hypot(z) < 1e-9 { o.light_azimuth } else { x.atan2(z).to_degrees() }; + [azimuth, (-y).clamp(-1.0, 1.0).asin().to_degrees()] + } + + /// A dragged light position on the front hemisphere, in normalized view coordinates. + pub fn light_at(point: Point) -> [f64; 2] { + let length = point.x.hypot(point.y).max(1.0); + let (x, y) = (point.x / length, point.y / length); + let z = (1.0 - x * x - y * y).max(0.0).sqrt(); + [x.atan2(z).to_degrees(), (-y).clamp(-1.0, 1.0).asin().to_degrees()] + } + + fn sanitized(self) -> Self { + Self { + angle: finite(self.angle, 360.0, 0.0, 360.0), + offset: finite(self.offset, 0.0, 0.0, 1e5), + rotation_x: finite(self.rotation_x, 0.0, -360.0, 360.0), + rotation_y: finite(self.rotation_y, 0.0, -360.0, 360.0), + rotation_z: finite(self.rotation_z, 0.0, -360.0, 360.0), + perspective: finite(self.perspective, 0.0, 0.0, 1.0), + segments: self.segments.clamp(8, 128), + light_azimuth: finite(self.light_azimuth, -45.0, -360.0, 360.0), + light_elevation: finite(self.light_elevation, 45.0, -90.0, 90.0), + light_intensity: finite(self.light_intensity, 0.8, 0.0, 1.0), + ambient: finite(self.ambient, 0.25, 0.0, 1.0), + ..self + } + } + fn rotate(self, v: V3) -> V3 { + let (sx, cx) = self.rotation_x.to_radians().sin_cos(); + let (sy, cy) = self.rotation_y.to_radians().sin_cos(); + let (sz, cz) = self.rotation_z.to_radians().sin_cos(); + let v = V3(v.0, cx * v.1 - sx * v.2, sx * v.1 + cx * v.2); + let v = V3(cy * v.0 + sy * v.2, v.1, -sy * v.0 + cy * v.2); + V3(cz * v.0 - sz * v.1, sz * v.0 + cz * v.1, v.2) + } +} + +/// One opaque vector polygon, already projected. Brightness multiplies the source colour. +#[derive(Clone, Debug, PartialEq)] +pub struct Face { + pub points: Vec, + pub depth: f64, + pub brightness: f64, +} + +impl Face { + pub fn path(&self) -> PathData { + let mut bp = BezPath::new(); + if let Some(first) = self.points.first() { + bp.move_to(*first); + for p in self.points.iter().skip(1) { + bp.line_to(*p); + } + bp.close_path(); + } + PathData::from_bezpath(&bp) + } +} + +/// Evaluate a profile in document space. All subpaths share one axis from `bounds`. +/// Invalid or excessively complex inputs return an error, never a partial surface. +pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result, String> { + let o = options.sanitized(); + if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { + return Err("Revolve requires finite profile coordinates within 100 million points".into()); + } + let axis = match o.edge { + Edge::Left => bounds.x0 - o.offset, + Edge::Right => bounds.x1 + o.offset, + }; + let center_y = (bounds.y0 + bounds.y1) * 0.5; + let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs()); + let distance = 4.0 * radius.max(bounds.height().abs()).max(1.0); + let steps = ((o.segments as f64 * o.angle / 360.0).ceil() as usize).max(1); + let sweep = o.angle.to_radians(); + let [lx, ly, lz] = o.light_direction(); + let light = V3(lx, ly, lz); + let mut faces = Vec::new(); + let mut count = 0; + for sp in &path.subpaths { + let mut profile = Vec::new(); + if let Some(first) = sp.anchors.first() { + profile.push(first.p); + count += 1; + if count > MAX_PROFILE_POINTS { + return Err("Revolve profile is too complex".into()); + } + } + let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + // Bound work before sampling any data. Curves get sixteen samples, straight edges one. + if segments > MAX_PROFILE_POINTS { + return Err("Revolve profile is too complex".into()); + } + for i in 0..segments { + let curve = sp.segment(i); + let samples = if sp.segment_is_line(i) { 1 } else { 16 }; + for k in 1..=samples { + profile.push(curve.eval(k as f64 / samples as f64)); + count += 1; + if count > MAX_PROFILE_POINTS { + return Err("Revolve profile is too complex".into()); + } + } + } + for pair in profile.windows(2) { + let [a, b] = pair else { continue }; + if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { + return Err("Revolve profile contains invalid coordinates".into()); + } + for j in 0..steps { + let vertex = |p: Point, step: usize| { + let (s, c) = (sweep * step as f64 / steps as f64).sin_cos(); + o.rotate(V3((p.x - axis) * c, p.y - center_y, (p.x - axis) * s)) + }; + let vertices = [vertex(*a, j), vertex(*b, j), vertex(*b, j + 1), vertex(*a, j + 1)]; + // The diagonal gives a nonzero normal even when one profile endpoint is on-axis. + let mut normal = vertices[1].sub(vertices[0]).cross(vertices[2].sub(vertices[0])).unit(); + if normal.dot(normal) < 0.5 { + normal = vertices[2].sub(vertices[0]).cross(vertices[3].sub(vertices[0])).unit(); + } + if normal.dot(normal) < 0.5 { + continue; + } + // Profiles may run up or down. Shade the visible side of this two-sided surface. + if normal.2 < 0.0 { + normal = V3(-normal.0, -normal.1, -normal.2); + } + let brightness = if o.shade { (o.ambient + o.light_intensity * normal.dot(light).max(0.0)).clamp(0.0, 1.0) } else { 1.0 }; + let points: Vec<_> = vertices + .iter() + .map(|v| { + let factor = distance / (distance - v.2 * o.perspective); + Point::new(axis + v.0 * factor, center_y + v.1 * factor) + }) + .collect(); + if points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + return Err("Revolve projection is not finite".into()); + } + let area = points + .iter() + .zip(points.iter().cycle().skip(1)) + .take(4) + .map(|(a, b)| (a.x - axis) * (b.y - center_y) - (b.x - axis) * (a.y - center_y)) + .sum::(); + if area.abs() < 1e-10 { + continue; + } + faces.push(Face { points, depth: vertices.iter().map(|v| v.2).sum::() / 4.0, brightness }); + if faces.len() > MAX_FACES { + return Err("Revolve surface is too complex; reduce Segments or simplify the profile".into()); + } + } + } + } + faces.sort_by(|a, b| a.depth.total_cmp(&b.depth)); + Ok(faces) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn light_orbits_and_handle_projection_match_the_shading_direction_on_both_sides() { + for azimuth in [-170.0f64, -45.0, 0.0, 83.0, 167.0] { + for elevation in [-85.0f64, -30.0, 0.0, 45.0, 90.0] { + let o = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..Default::default() }; + let direction = o.light_direction(); + for ([center, u, v], angle) in o.light_orbits().into_iter().zip([azimuth, elevation]) { + let (s, c) = angle.to_radians().sin_cos(); + for i in 0..3 { + assert!((center[i] + u[i] * c + v[i] * s - direction[i]).abs() < 1e-12); + } + } + let [a, e] = o.light_at_on_side(Point::new(direction[0], direction[1])); + assert!((a - azimuth).abs() < 1e-9 && (e - elevation).abs() < 1e-9, "{azimuth}, {elevation} -> {a}, {e}"); + } + } + let o = Revolve { light_azimuth: 135.0, ..Default::default() }; + let [azimuth, elevation] = o.light_at_on_side(Point::new(0.3, -0.4)); + let [x, y, z] = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..o }.light_direction(); + assert!((x - 0.3).abs() < 1e-12 && (y + 0.4).abs() < 1e-12 && z < 0.0); + let [a, e] = o.light_at_on_side(Point::new(f64::NAN, f64::INFINITY)); + assert!(a.is_finite() && e.is_finite()); + } + + #[test] + fn ring_axes_match_euler_parameter_rotation_and_origin_stays_fixed() { + let o = Revolve { rotation_x: 27.0, rotation_y: -39.0, rotation_z: 53.0, ..Default::default() }; + let seed = V3(0.3, -0.5, 0.7); + let before = o.rotate(seed); + let planes = o.rotation_planes(); + for (axis, [u, v]) in planes.iter().enumerate() { + let normal = V3(u[0], u[1], u[2]).cross(V3(v[0], v[1], v[2])); + assert!((normal.dot(normal) - 1.0).abs() < 1e-12); + let mut changed = o; + match axis { + 0 => changed.rotation_x += 0.0001, + 1 => changed.rotation_y += 0.0001, + _ => changed.rotation_z += 0.0001, + } + let actual = changed.rotate(seed).sub(before); + let expected = normal.cross(before); + let step = 0.0001f64.to_radians(); + for (a, b) in [(actual.0 / step, expected.0), (actual.1 / step, expected.1), (actual.2 / step, expected.2)] { + assert!((a - b).abs() < 1e-5); + } + } + assert_eq!(Revolve { edge: Edge::Right, offset: 12.0, ..o }.rotation_origin(Rect::new(20.0, 30.0, 80.0, 100.0)), Point::new(92.0, 65.0)); + } + #[test] + fn guides_match_projection_and_dragged_light_matches_shading_direction() { + let o = Revolve { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, offset: 10.0, ..Default::default() }; + assert_eq!(o.view_axes(), [Point::new(1.0, 0.0), Point::new(0.0, 1.0), Point::new(0.0, 0.0)]); + assert_eq!(o.projected_axis(Rect::new(20.0, 30.0, 80.0, 100.0)), [Point::new(10.0, 30.0), Point::new(10.0, 100.0)]); + let [azimuth, elevation] = Revolve::light_at(Point::new(0.3, -0.4)); + let [x, y, z] = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..o }.light_direction(); + assert!((x - 0.3).abs() < 1e-9 && (y + 0.4).abs() < 1e-9 && z > 0.0); + let [azimuth, elevation] = Revolve::light_at(Point::new(2.0, -2.0)); + assert!(azimuth.is_finite() && elevation.is_finite() && elevation.abs() <= 90.0); + } + fn cylinder() -> (PathData, Rect) { + let mut bp = BezPath::new(); + bp.move_to((40.0, 10.0)); + bp.line_to((40.0, 90.0)); + (PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0)) + } + #[test] + fn cylinder_has_expected_silhouette_depth_order_and_shading() { + let (p, b) = cylinder(); + let faces = revolve(&p, b, Revolve { rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap(); + let points: Vec<_> = faces.iter().flat_map(|f| &f.points).collect(); + assert!((points.iter().map(|p| p.x).fold(f64::INFINITY, f64::min) + 40.0).abs() < 1e-6); + assert!((points.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max) - 40.0).abs() < 1e-6); + assert!(faces.windows(2).all(|w| w[0].depth <= w[1].depth)); + assert!(faces.iter().any(|f| f.brightness > 0.8) && faces.iter().any(|f| f.brightness < 0.4)); + } + #[test] + fn edge_offset_sweep_and_rotation_change_the_surface() { + let (p, b) = cylinder(); + let base = revolve(&p, b, Revolve::default()).unwrap(); + for o in [ + Revolve { edge: Edge::Right, offset: 20.0, ..Default::default() }, + Revolve { angle: 120.0, ..Default::default() }, + Revolve { rotation_z: 60.0, perspective: 0.8, ..Default::default() }, + ] { + assert_ne!(base, revolve(&p, b, o).unwrap()); + } + assert!(revolve(&p, b, Revolve { angle: 0.0, ..Default::default() }).unwrap().is_empty()); + } + #[test] + fn malformed_and_degenerate_inputs_are_bounded() { + let (p, b) = cylinder(); + let f = revolve(&p, b, Revolve { segments: usize::MAX, angle: f64::NAN, offset: f64::INFINITY, ..Default::default() }).unwrap(); + assert!(f.len() <= 128); + assert!(revolve(&p, Rect::new(f64::NAN, 0.0, 1.0, 1.0), Revolve::default()).is_err()); + assert!(revolve(&PathData::default(), b, Revolve::default()).unwrap().is_empty()); + } +} diff --git a/crates/three-d/src/visibility.rs b/crates/three-d/src/visibility.rs new file mode 100644 index 00000000..ce2bfa74 --- /dev/null +++ b/crates/three-d/src/visibility.rs @@ -0,0 +1,325 @@ +//! Vector visibility in the evaluator's paint order. Convex projected faces are cut by the +//! nearer faces covering them, irrespective of their winding or which side faces the camera. +//! This preserves interiors visible through open ends and partial sweeps. + +use vectorcraft_geom::{PathData, Point, Rect, SubPath}; + +use crate::{Face, MAX_FACES}; + +const GRID: usize = 32; +const MAX_GRID_REFS: usize = 2_000_000; +const MAX_WORK: usize = 16_000_000; +const MAX_FRAGMENTS: usize = 256; +const MAX_OUTPUT_POINTS: usize = 262_144; + +/// The visible portions of one source face, with its original shading and paint order. +#[derive(Clone, Debug, PartialEq)] +pub struct VisibleFace { + pub path: PathData, + pub brightness: f64, + pub depth: f64, +} + +struct Polygon { + points: Vec, + bounds: Rect, +} + +fn bounds(points: &[Point]) -> Rect { + let Some(first) = points.first() else { return Rect::ZERO }; + points.iter().fold(Rect::from_points(*first, *first), |b, p| b.union_pt(*p)) +} + +fn area(points: &[Point]) -> f64 { + let Some(origin) = points.first() else { return 0.0 }; + points.iter().zip(points.iter().cycle().skip(1)).take(points.len()).map(|(a, b)| (*a - *origin).cross(*b - *origin)).sum::() / 2.0 +} + +fn clean(mut points: Vec, epsilon: f64) -> Vec { + points.dedup_by(|a, b| a.distance(*b) <= epsilon); + if points.len() > 1 && points.first().zip(points.last()).is_some_and(|(a, b)| a.distance(*b) <= epsilon) { + points.pop(); + } + if points.len() < 3 || area(&points).abs() <= epsilon * epsilon { + return vec![]; + } + if area(&points) < 0.0 { + points.reverse(); + } + points +} + +fn overlaps(a: Rect, b: Rect) -> bool { + a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 +} + +/// Split a convex polygon at an occluder's edge. Boundary vertices belong to both halves, +/// so independently clipped neighbours share precisely the same intersection positions. +fn split(points: &[Point], a: Point, b: Point, epsilon: f64) -> (Vec, Vec) { + let edge = b - a; + let mut inside = vec![]; + let mut outside = vec![]; + for (p, q) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()) { + let dp = edge.cross(*p - a); + let dq = edge.cross(*q - a); + if dp >= 0.0 { + inside.push(*p); + } + if dp <= 0.0 { + outside.push(*p); + } + if (dp > 0.0 && dq < 0.0) || (dp < 0.0 && dq > 0.0) { + let intersection = p.lerp(*q, dp / (dp - dq)); + inside.push(intersection); + outside.push(intersection); + } + } + (clean(inside, epsilon), clean(outside, epsilon)) +} + +fn subtract(points: Vec, cover: &Polygon, epsilon: f64, work: &mut usize) -> Result>, String> { + if !overlaps(bounds(&points), cover.bounds) { + return Ok(vec![points]); + } + let mut remaining = points; + let mut visible = vec![]; + for (a, b) in cover.points.iter().zip(cover.points.iter().cycle().skip(1)).take(cover.points.len()) { + *work += remaining.len(); + if *work > MAX_WORK { + return Err("Revolve visibility exceeds its geometry work limit".into()); + } + let (inside, outside) = split(&remaining, *a, *b, epsilon); + if !outside.is_empty() { + visible.push(outside); + } + remaining = inside; + if remaining.is_empty() { + break; + } + } + Ok(visible) +} + +struct Index { + cells: Vec>, + bounds: Rect, + refs: usize, +} + +impl Index { + fn tiles(&self, b: Rect) -> impl Iterator { + let cell = |v: f64, lo: f64, size: f64| (((v - lo) / size * GRID as f64).floor() as usize).min(GRID - 1); + let x0 = cell(b.x0, self.bounds.x0, self.bounds.width()); + let x1 = cell(b.x1, self.bounds.x0, self.bounds.width()); + let y0 = cell(b.y0, self.bounds.y0, self.bounds.height()); + let y1 = cell(b.y1, self.bounds.y0, self.bounds.height()); + (y0..=y1).flat_map(move |y| (x0..=x1).map(move |x| y * GRID + x)) + } + + fn insert(&mut self, b: Rect, face: usize) -> Result<(), String> { + let tiles: Vec<_> = self.tiles(b).collect(); + self.refs += tiles.len(); + if self.refs > MAX_GRID_REFS { + return Err("Revolve visibility exceeds its spatial index limit".into()); + } + for tile in tiles { + if let Some(cell) = self.cells.get_mut(tile) { + cell.push(face); + } + } + Ok(()) + } +} + +/// Remove covered portions of opaque faces in back-to-front paint order. This follows the +/// live evaluator's painter visibility, including its approximation for intersecting surfaces. +/// Resource limits return an error; callers can retain the original complete surface safely. +pub fn visible_faces(faces: &[Face]) -> Result, String> { + if faces.len() > MAX_FACES { + return Err("Too many Revolve faces for visibility evaluation".into()); + } + if faces.iter().any(|f| { + f.points.len() > 4 + || !f.brightness.is_finite() + || !f.depth.is_finite() + || f.points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs().max(p.y.abs()) > 1e8) + }) { + return Err("Revolve visibility requires finite projected convex faces".into()); + } + let all = faces.iter().flat_map(|f| &f.points).copied().collect::>(); + let total = bounds(&all); + if total.width() <= 0.0 || total.height() <= 0.0 { + return Ok(vec![]); + } + let epsilon = (total.width().max(total.height()) * 1e-10).clamp(1e-8, 1e-3); + let polygons: Vec<_> = faces + .iter() + .map(|f| { + let points = clean(f.points.clone(), epsilon); + Polygon { bounds: bounds(&points), points } + }) + .collect(); + // Generated quads are convex. Validate the public helper rather than accepting concave + // callers and silently deleting geometry they can see. + for p in &polygons { + if p.points.iter().enumerate().any(|(i, a)| { + let Some(b) = p.points.get((i + 1) % p.points.len()) else { return false }; + let Some(c) = p.points.get((i + 2) % p.points.len()) else { return false }; + (*b - *a).cross(*c - *b) < -epsilon * b.distance(*a).max(1.0) + }) { + return Err("Revolve visibility requires convex projected faces".into()); + } + } + let mut index = Index { cells: vec![vec![]; GRID * GRID], bounds: total, refs: 0 }; + let mut seen = vec![usize::MAX; faces.len()]; + let (mut out, mut work, mut output_points) = (vec![], 0, 0); + for (source, (face, polygon)) in faces.iter().zip(&polygons).enumerate().rev() { + if polygon.points.is_empty() { + continue; + } + let mut fragments = vec![polygon.points.clone()]; + 'covers: for tile in index.tiles(polygon.bounds) { + for &candidate in index.cells.get(tile).into_iter().flatten() { + work += 1; + if work > MAX_WORK { + return Err("Revolve visibility exceeds its geometry work limit".into()); + } + if let Some(mark) = seen.get_mut(candidate) + && *mark != source + { + *mark = source; + if let Some(cover) = polygons.get(candidate) + && overlaps(polygon.bounds, cover.bounds) + { + let mut remaining = vec![]; + for fragment in fragments { + remaining.extend(subtract(fragment, cover, epsilon, &mut work)?); + if remaining.len() > MAX_FRAGMENTS { + return Err("Revolve visibility creates too many fragments".into()); + } + } + fragments = remaining; + if fragments.is_empty() { + break 'covers; + } + } + } + } + } + if fragments.is_empty() { + continue; + } + output_points += fragments.iter().map(Vec::len).sum::(); + if output_points > MAX_OUTPUT_POINTS { + return Err("Revolve visibility creates too many vector anchors".into()); + } + let path = PathData::new(fragments.into_iter().map(|p| SubPath::polyline(&p, true)).collect()); + out.push(VisibleFace { path, brightness: face.brightness, depth: face.depth }); + // A completely covered face need not occupy the grid. Its coverage already belongs + // to nearer faces; this keeps repeated overlapping surfaces bounded in practice. + index.insert(polygon.bounds, source)?; + } + out.reverse(); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Edge, Revolve, revolve}; + use vectorcraft_geom::{BezPath, Shape}; + + fn face(rect: Rect, brightness: f64) -> Face { + Face { + points: vec![Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)], + depth: brightness, + brightness, + } + } + + #[test] + fn covered_faces_disappear_and_partial_faces_are_trimmed_in_paint_order() { + let faces = + vec![face(Rect::new(2.0, 2.0, 8.0, 8.0), 0.1), face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.2), face(Rect::new(5.0, 0.0, 15.0, 10.0), 0.8)]; + let visible = visible_faces(&faces).unwrap(); + assert_eq!(visible.len(), 2); + assert_eq!(visible[0].brightness, 0.2); + assert_eq!(visible[0].path.bounds(), Some(Rect::new(0.0, 0.0, 5.0, 10.0))); + assert!((visible[0].path.to_bezpath().area() - 50.0).abs() < 1e-8); + assert!((visible[1].path.to_bezpath().area() - 100.0).abs() < 1e-8); + // Winding and equal depth do not determine visibility; actual paint order does. + let mut flipped = faces.clone(); + for f in &mut flipped { + f.points.reverse(); + f.depth = 0.0; + } + let other = visible_faces(&flipped).unwrap(); + assert_eq!(visible.iter().map(|f| f.brightness).collect::>(), other.iter().map(|f| f.brightness).collect::>()); + assert_eq!(visible[0].path.bounds(), other[0].path.bounds()); + } + + #[test] + fn an_interior_surface_remains_visible_through_an_opening() { + let faces = vec![ + face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.1), + face(Rect::new(0.0, 0.0, 10.0, 3.0), 0.2), + face(Rect::new(0.0, 7.0, 10.0, 10.0), 0.3), + face(Rect::new(0.0, 3.0, 3.0, 7.0), 0.4), + face(Rect::new(7.0, 3.0, 10.0, 7.0), 0.5), + ]; + let visible = visible_faces(&faces).unwrap(); + assert_eq!(visible[0].brightness, 0.1); + assert_eq!(visible[0].path.bounds(), Some(Rect::new(3.0, 3.0, 7.0, 7.0))); + assert_eq!(visible[0].path.to_bezpath().winding(Point::new(5.0, 5.0)), 1); + assert_eq!(visible[0].path.to_bezpath().winding(Point::new(1.0, 1.0)), 0); + } + + #[test] + fn revolved_open_profiles_match_visible_paint_for_both_axis_sides_and_perspective() { + let mut bp = BezPath::new(); + bp.move_to((40.0, 10.0)); + bp.curve_to((75.0, 20.0), (75.0, 80.0), (40.0, 90.0)); + let path = PathData::from_bezpath(&bp); + for edge in [Edge::Left, Edge::Right] { + for angle in [155.0, 360.0] { + let options = Revolve { + edge, + angle, + offset: 8.0, + segments: 32, + rotation_x: -32.0, + rotation_y: 24.0, + rotation_z: 19.0, + perspective: 0.7, + ..Default::default() + }; + let faces = revolve(&path, path.bounds().unwrap(), options).unwrap(); + let visible = visible_faces(&faces).unwrap(); + assert!(visible.len() <= faces.len()); + let all: Vec<_> = faces.iter().map(|f| (f.path().to_bezpath(), f.brightness)).collect(); + let cut: Vec<_> = visible.iter().map(|f| (f.path.to_bezpath(), f.brightness)).collect(); + let b = bounds(&faces.iter().flat_map(|f| &f.points).copied().collect::>()); + for x in 0..48 { + for y in 0..48 { + let p = Point::new(b.x0 + b.width() * (x as f64 + 0.337) / 48.0, b.y0 + b.height() * (y as f64 + 0.413) / 48.0); + let expected = all.iter().rev().find(|(bp, _)| bp.winding(p) != 0).map(|(_, value)| value); + let actual = cut.iter().rev().find(|(bp, _)| bp.winding(p) != 0).map(|(_, value)| value); + assert_eq!(actual, expected, "{edge:?}, {angle}, {p:?}"); + assert!(cut.iter().filter(|(bp, _)| bp.winding(p) != 0).count() <= 1, "expanded surfaces do not cover hidden regions"); + } + } + } + } + } + + #[test] + fn visibility_is_bounded_for_repeated_faces_and_rejects_invalid_input() { + let faces = vec![face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.5); MAX_FACES]; + assert_eq!(visible_faces(&faces).unwrap().len(), 1); + assert!(visible_faces(&vec![faces[0].clone(); MAX_FACES + 1]).is_err()); + let mut bad = faces[0].clone(); + bad.points[0].x = f64::NAN; + assert!(visible_faces(&[bad]).is_err()); + assert!(visible_faces(&[]).unwrap().is_empty()); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 872d33ea..d3299868 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -19,6 +19,7 @@ vectorcraft-tools = { workspace = true } vectorcraft-engine = { workspace = true } vectorcraft-svg = { workspace = true } vectorcraft-effects = { workspace = true } +vectorcraft-three-d = { workspace = true } vectorcraft-plugins = { workspace = true } vectorcraft-pdf = { workspace = true } vectorcraft-cad = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ea2ed01b..8cfcfe58 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -175,7 +175,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag()); // A press on the canvas while the context menu is open only closes it (a drag too, which egui // alone would leave open); the tool doesn't get it. - if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) { + if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) { + // The artboard's 3D gizmo owns these gestures while its options are open. + // Do not let a ring drag also move or deselect the editable source path. + } else if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) { egui::Popup::close_all(ui.ctx()); } else { handle_input(app, ui, &resp, rect); @@ -401,7 +404,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } // Selection visuals and tool overlays. - if app.ui.view.edges { + let revolve_edit = app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active); + if app.ui.view.edges && !revolve_edit { hover_highlight(app, &painter, &xf); selection_overlay(app, &painter, &xf); if app.ui.view.text_threads { @@ -413,7 +417,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } let view_info = app.view_info(); // View → Hide Gradient Annotator hides the Gradient tool's annotator. - if app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator { + if !revolve_edit && (app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator) { let overlays = app.session.overlays(view_info); draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs)); } @@ -423,7 +427,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if app.ui.view.rulers && app.ui.screen_mode < 3 { rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), &t); } - if app.ui.task_bar && !app.session.tool_busy() && app.ui.screen_mode < 3 { + if app.ui.task_bar && !revolve_edit && !app.session.tool_busy() && app.ui.screen_mode < 3 { task_bar(app, ui, &xf); } if app.ui.screen_mode < 3 { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 2d029eef..224d5dff 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -809,10 +809,14 @@ pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) { ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", theme::semibold(11.0), t.text); ui.add_space(6.0); let mut job = egui::text::LayoutJob::default(); - let tool = app.session.tool_id(); - // The Zoom tool's drag zooms to an area, or with Animated Zoom zooms as it goes. - let hint = if tool == "zoom" && crate::canvas::animated_zoom(&app.session.prefs) { ANIMATED_ZOOM_HINT } else { tool }; - for (txt, bold) in hint_segments(hint) { + let segments = if let Some(d) = app.ui.dialog.as_ref().filter(|d| crate::dialogs::revolve_gizmo::active(d)) { + vec![(crate::dialogs::revolve_gizmo::hint(d).into(), false)] + } else { + let tool = app.session.tool_id(); + let hint = if tool == "zoom" && crate::canvas::animated_zoom(&app.session.prefs) { ANIMATED_ZOOM_HINT } else { tool }; + hint_segments(hint) + }; + for (txt, bold) in segments { let font = if bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) }; job.append(&txt, 0.0, egui::TextFormat { font_id: font, color: if bold { t.text_strong } else { t.text }, ..Default::default() }); } diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index e1d21a60..1337e341 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -127,6 +127,12 @@ fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let (id, relative) = (d.str("__effect"), d.bool("relative")); + if id == vectorcraft_effects::REVOLVE { + super::revolve::body(app, ui, d); + let (cmd, params) = command(d); + form::preview(app, ui, d, &d.str("__label"), cmd, params); + return false; + } // Plug-in effects get fields from their parameter schema; colour adjustments their sliders. let changed = match vectorcraft_plugins::effect::installed(&id) { Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params), diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 78e7fd8b..09b9c290 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -67,6 +67,8 @@ pub mod raster_effects; pub mod rearrange_artboards; pub mod recolor; mod recovery; +mod revolve; +pub(crate) mod revolve_gizmo; pub mod saturate; mod save_changes; pub mod save_for_web; @@ -355,6 +357,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { // its first field is to take the keyboard focus (`focus_id`, until a field takes it). let (shown_id, focus_id) = (egui::Id::new("dialog-shown"), egui::Id::new("dialog-focus-pending")); let Some(mut d) = app.ui.dialog.clone() else { + revolve_gizmo::clear(ctx); app.ui.dialog_file = None; ctx.data_mut(|m| { m.remove::(shown_id); @@ -362,6 +365,9 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); return; }; + if !revolve_gizmo::active(&d) { + revolve_gizmo::clear(ctx); + } let focus_first = ctx.data_mut(|m| { if m.get_temp::(shown_id).as_deref() != Some(d.kind.as_str()) { m.insert_temp(shown_id, d.kind.clone()); @@ -377,7 +383,14 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let mut cancel = false; let mut discard = false; let heading = (spec.heading)(&d); - modal::show(ctx, &heading, egui::Id::new(("dialog", d.kind.as_str())), -40.0, MARGIN, |ui| { + let revolves = revolve_gizmo::active(&d); + if revolves { + revolve_gizmo::show(app, ctx, &mut d); + } + let initial = if revolves { egui::vec2((ctx.content_rect().width() / 2.0 - 226.0).max(0.0), -40.0) } else { egui::vec2(0.0, -40.0) }; + let id = egui::Id::new(("dialog", d.kind.as_str())); + let id = if revolves { id.with("revolve") } else { id }; + modal::show_at(ctx, &heading, id, initial, MARGIN, |ui| { // Never wider than the window (a large UI scale in a small window): the text wraps. let room = (ctx.content_rect().width() - 2.0 * (f32::from(MARGIN) + EDGE_GAP)).max(EDGE_GAP); ui.set_min_width(spec.min_width.min(room)); diff --git a/crates/ui-egui/src/dialogs/modal.rs b/crates/ui-egui/src/dialogs/modal.rs index dcd41573..164471fc 100644 --- a/crates/ui-egui/src/dialogs/modal.rs +++ b/crates/ui-egui/src/dialogs/modal.rs @@ -11,6 +11,11 @@ use crate::theme::{self, Tokens}; /// screen's centre until it is moved, with `margin` inside its frame. `add` draws the contents and /// should start with [`heading`] (or [`drag_band`]) so the window can be moved. pub(crate) fn show(ctx: &egui::Context, title: &str, id: Id, nudge: f32, margin: i8, add: impl FnOnce(&mut egui::Ui) -> R) -> Option { + show_at(ctx, title, id, egui::vec2(0.0, nudge), margin, add) +} + +/// A modal that starts at a chosen offset, then keeps the user's dragged position. +pub(crate) fn show_at(ctx: &egui::Context, title: &str, id: Id, initial: Vec2, margin: i8, add: impl FnOnce(&mut egui::Ui) -> R) -> Option { let t = Tokens::get(ctx); let dim = backdrop(); egui::Area::new(dim.id).order(dim.order).fixed_pos(egui::Pos2::ZERO).show(ctx, |ui| { @@ -23,7 +28,7 @@ pub(crate) fn show(ctx: &egui::Context, title: &str, id: Id, nudge: f32, marg .collapsible(false) .resizable(false) .title_bar(false) - .anchor(egui::Align2::CENTER_CENTER, offset(ctx, id, nudge)) + .anchor(egui::Align2::CENTER_CENTER, offset(ctx, id, initial)) .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin))) .show(ctx, add) .and_then(|r| r.inner) @@ -67,9 +72,9 @@ fn handle(id: Id) -> Id { /// its [`drag_band`] is dragged, where it sat when the drag began plus how far the pointer has moved /// since the press, kept on screen (applied before the window places itself, so it follows the /// pointer without a frame's lag). -fn offset(ctx: &egui::Context, id: Id, nudge: f32) -> Vec2 { +fn offset(ctx: &egui::Context, id: Id, initial: Vec2) -> Vec2 { let (key, start_key) = (id.with("modal-offset"), id.with("modal-drag-start")); - let offset = ctx.data(|m| m.get_temp::(key)).unwrap_or(egui::vec2(0.0, nudge)); + let offset = ctx.data(|m| m.get_temp::(key)).unwrap_or(initial); let dragged = ctx.read_response(handle(id)).is_some_and(|r| r.dragged()); let moved = ctx.input(|i| Some(i.pointer.latest_pos()? - i.pointer.press_origin()?)); let (true, Some(moved), Some(rect)) = (dragged, moved, ctx.memory(|m| m.area_rect(id))) else { diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs new file mode 100644 index 00000000..2d2b09cc --- /dev/null +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -0,0 +1,233 @@ +//! Compact Revolve controls. The object and rotation gizmo live on the artboard. +//! All document changes run through the effect dialog's existing preview/commit commands. + +use egui::{Align2, FontId, Id, Pos2, Sense, Stroke, vec2}; +use serde_json::{Value, json}; +use vectorcraft_doc::Unit; +use vectorcraft_effects::REVOLVE; + +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, scrub, widgets}; + +const LABEL_W: f32 = 102.0; +const VALUE_W: f32 = 74.0; +const SIDE_W: f32 = 354.0; + +/// Sized to the screen, with scrolling inside the body so OK and Cancel stay reachable. +pub(super) fn body(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + // Values start in drag mode; clicking or Tab enters their text editor. + ui.data_mut(|m| m.remove::(widgets::dialog_focus_flag())); + let screen = ui.ctx().content_rect(); + let width = (screen.width() - 64.0).clamp(240.0, SIDE_W); + let height = (screen.height() - 240.0).clamp(120.0, 340.0); + ui.set_width(width); + egui::ScrollArea::vertical().id_salt("revolve-body").max_height(height).auto_shrink([false, true]).show(ui, |ui| { + controls(app, ui, d); + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + let t = Tokens::get(ui.ctx()); + ui.allocate_ui_with_layout(vec2((width - 100.0).max(80.0), 32.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.add(egui::Label::new(egui::RichText::new(super::revolve_gizmo::hint(d)).size(11.0).color(t.text_dim)).wrap()); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let defaults = vectorcraft_effects::default_params(REVOLVE).unwrap_or(json!({})); + let changed = defaults.as_object().is_some_and(|m| { + m.iter().any(|(k, v)| v.as_f64().map_or_else(|| d.fields.get(k) != Some(v), |default| (d.f64(k, default) - default).abs() > 1e-6)) + }); + let (r, _) = ui.allocate_exact_size(vec2(88.0, 24.0), Sense::hover()); + let response = ui.interact(r, Id::new("revolve-reset-all"), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset all"))); + if response.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + ui.painter().text(r.left_center() + vec2(4.0, 0.0), Align2::LEFT_CENTER, tl!("Reset all"), FontId::proportional(11.5), t.text); + dot(ui, r.right_center() - vec2(10.0, 0.0), changed); + if response.on_hover_text(tl!("Reset all Revolve settings to their defaults.")).clicked() + && let Some(defaults) = defaults.as_object() + { + d.fields.extend(defaults.clone()); + } + }); + }); +} + +fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + ui.spacing_mut().item_spacing.y = 4.0; + let lighting = super::revolve_gizmo::lighting(d); + if let Some(tab) = widgets::tab_bar(ui, &[tl!("Rotation"), tl!("Lighting")], usize::from(lighting)) { + d.fields.insert("__gizmo".into(), json!(if tab == 1 { "light" } else { "rotation" })); + } + ui.add_space(8.0); + if super::revolve_gizmo::lighting(d) { + lighting_controls(ui, d); + return; + } + let unit = app.session.general_unit(); + heading(ui, tl!("Shape")); + number(ui, d, "angle", tl!("Revolve angle"), (0.0, 360.0), "°", 1, None); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + label(ui, tl!("Axis side")); + let edge = d.str("edge"); + let current = if edge == "right" { tl!("Right") } else { tl!("Left") }; + let width = VALUE_W + rail_width(ui) + 6.0; + if let Some(k) = widgets::dropdown(ui, "revolve-edge", current, &[tl!("Left"), tl!("Right")], width) { + d.fields.insert("edge".into(), json!(if k == 0 { "left" } else { "right" })); + } + if reset_dot(ui, "edge", edge != "left", tl!("Left")) { + d.fields.insert("edge".into(), json!("left")); + } + }); + number(ui, d, "offset", tl!("Offset"), (0.0, 100000.0), "", 1, Some(unit)); + ui.add_space(10.0); + heading(ui, tl!("View")); + for (key, name) in [("rotationX", tl!("Rotation X")), ("rotationY", tl!("Rotation Y")), ("rotationZ", tl!("Rotation Z"))] { + number(ui, d, key, name, (-360.0, 360.0), "°", 1, None); + } + number(ui, d, "perspective", tl!("Perspective"), (0.0, 100.0), "%", 1, None); + ui.add_space(6.0); + egui::CollapsingHeader::new(tl!("Advanced")).id_salt("revolve-advanced").show(ui, |ui| { + number(ui, d, "segments", tl!("Segments"), (8.0, 128.0), "", 0, None); + super::form::check(ui, d, "__showAxis", tl!("Show axis")); + ui.add_space(6.0); + ui.horizontal(|ui| { + let mut visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true); + let response = ui.allocate_ui_with_layout( + vec2((ui.available_width() - 30.0).max(100.0), 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.checkbox(&mut visible, egui::RichText::new(tl!("Keep visible surfaces only when expanding")).size(12.0)) + }, + ).inner; + if response.on_hover_text(tl!("Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces.")).changed() { + d.fields.insert("expandVisibleOnly".into(), json!(visible)); + } + if reset_dot(ui, "expandVisibleOnly", !visible, "true") { + d.fields.insert("expandVisibleOnly".into(), json!(true)); + } + }); + }); +} + +fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) { + ui.horizontal(|ui| { + let mut shade = d.bool("shade"); + if ui.checkbox(&mut shade, tl!("Shading")).changed() { + d.fields.insert("shade".into(), json!(shade)); + } + ui.add_space((ui.available_width() - 24.0).max(0.0)); + if reset_dot(ui, "shade", !shade, "true") { + d.fields.insert("shade".into(), json!(true)); + } + }); + ui.add_enabled_ui(d.bool("shade"), |ui| { + number(ui, d, "lightAzimuth", tl!("Direction"), (-360.0, 360.0), "°", 1, None); + number(ui, d, "lightElevation", tl!("Elevation"), (-90.0, 90.0), "°", 1, None); + number(ui, d, "lightIntensity", tl!("Intensity"), (0.0, 100.0), "%", 1, None); + number(ui, d, "ambient", tl!("Ambient"), (0.0, 100.0), "%", 1, None); + }); +} + +fn heading(ui: &mut egui::Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(text).font(crate::theme::semibold(13.0)).color(t.text)); +} + +fn label(ui: &mut egui::Ui, name: &str) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 24.0), Sense::hover()); + ui.put(r, egui::Label::new(egui::RichText::new(name).size(12.5).color(t.text)).halign(egui::Align::Min).truncate()).on_hover_text(name); + scrub::note_label(ui, r); +} + +fn rail_width(ui: &egui::Ui) -> f32 { + (ui.available_width() - VALUE_W - 24.0 - 18.0).clamp(40.0, 134.0) +} + +/// A value can be typed, dragged, scrubbed by its label, or moved on its slider. +#[allow(clippy::too_many_arguments)] +fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, f64), suffix: &str, decimals: usize, unit: Option) { + let default = vectorcraft_effects::default_params(REVOLVE).and_then(|p| p.get(key).and_then(Value::as_f64)).unwrap_or(0.0); + let from = |n| unit.map_or(n, |u| u.from_pt(n)); + let to = |n| unit.map_or(n, |u| u.to_pt(n)); + let typed = unit.map(|u| widgets::typed_unit(ui.ctx(), u)); + let mut value = from(d.f64(key, default)).clamp(from(range.0), from(range.1)); + let suffix = unit.map_or_else(|| suffix.to_string(), |u| format!(" {}", u.suffix())); + let mut next = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + label(ui, name); + let t = Tokens::get(ui.ctx()); + let response = ui + .scope(|ui| { + ui.visuals_mut().widgets.inactive.weak_bg_fill = t.input; + ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::new(1.0, t.input_border); + ui.add_sized( + [VALUE_W, 24.0], + egui::DragValue::new(&mut value) + .range(from(range.0)..=from(range.1)) + .speed(if decimals == 0 { 1.0 } else { 0.25 }) + .max_decimals(decimals) + .suffix(&suffix) + .update_while_editing(false) + .custom_parser(|s| { + unit.map_or_else( + || vectorcraft_doc::parse_number(&s.replace(['°', '%'], "")), + |u| typed.unwrap_or(u).parse(s).map(|v| u.from_pt(v)), + ) + }), + ) + }) + .inner; + response.clone().on_hover_text(tl!("Drag values or labels. Double-click a value to type.")); + if response.changed() { + next = Some(value); + } + if let Some(n) = scrub::field(ui, response.id, response.rect, &value.to_string(), Some(value), decimals as i32) { + next = Some(n); + } + // Offset's rail covers a useful working range; larger values can still be typed. + let rail_max = if key == "offset" { from(500.0) } else { from(range.1) }; + let rail_min = from(range.0); + let rail = rail_width(ui) + VALUE_W; + let slider = + widgets::color_slider(ui, ("revolve-slider", key), ((value - rail_min) / (rail_max - rail_min)).clamp(0.0, 1.0) as f32, rail, &|_| { + t.input_border + }); + if let (Some(n), _) = slider { + next = Some(rail_min + f64::from(n) * (rail_max - rail_min)); + } + if reset_dot(ui, key, (to(value) - default).abs() > 1e-6, &format!("{}{}", from(default), suffix)) { + next = Some(from(default)); + } + }); + if let Some(n) = next.filter(|n| n.is_finite()) { + let factor = 10f64.powi(decimals as i32); + let n = (n * factor).round() / factor; + d.fields.insert(key.into(), json!(to(n.clamp(from(range.0), from(range.1))))); + } +} + +fn dot(ui: &egui::Ui, p: Pos2, changed: bool) { + let t = Tokens::get(ui.ctx()); + if changed { + ui.painter().circle_filled(p, 3.0, if ui.is_enabled() { t.accent } else { t.text_disabled }); + } else { + ui.painter().circle_stroke(p, 3.0, Stroke::new(1.0, t.text_disabled)); + } +} + +fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool { + let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); + let response = ui.interact(r, Id::new(("revolve-reset", key)), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset to default"))); + if response.hovered() { + ui.painter().rect_filled(r, 3.0, Tokens::get(ui.ctx()).hover); + } + dot(ui, r.center(), changed); + response.on_hover_text(format!("{}: {default}", tl!("Reset to default"))).clicked() +} + +#[cfg(test)] +#[path = "tests_revolve.rs"] +mod tests; diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs new file mode 100644 index 00000000..e00e1377 --- /dev/null +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -0,0 +1,425 @@ +//! Artboard rotation and light controls for the active Revolve dialog. Gestures edit fields; +//! the existing effect preview applies commands and owns OK, Cancel and undo. +use egui::{Align2, FontId, Id, Pos2, Rect, Sense, Stroke, Vec2, vec2}; +use serde_json::json; +use vectorcraft_effects::{REVOLVE, revolve_options}; +use vectorcraft_geom::Point; +use vectorcraft_three_d::Revolve; + +use crate::{VectorcraftApp, canvas::Xf, state::Dialog, theme::Tokens}; + +const KEYS: [&str; 3] = ["rotationX", "rotationY", "rotationZ"]; +const SAMPLES: usize = 96; + +pub(crate) fn active(d: &Dialog) -> bool { + d.kind == "effect" && d.str("__effect") == REVOLVE +} + +pub(crate) fn lighting(d: &Dialog) -> bool { + d.str("__gizmo") == "light" +} + +pub(crate) fn hint(d: &Dialog) -> &'static str { + if !d.bool("preview") || (lighting(d) && !d.bool("shade")) { + tl!("Drag values or labels. Double-click a value to type.") + } else if lighting(d) { + tl!("Drag the light, or its rings. Shift snaps to 15°.") + } else { + tl!("Drag rings to rotate. Shift snaps to 15°.") + } +} + +fn drag_id() -> Id { + Id::new("revolve-gizmo-drag") +} + +pub(crate) fn dragging(ctx: &egui::Context) -> bool { + ctx.data(|m| m.get_temp::(drag_id())).is_some() +} + +pub(super) fn clear(ctx: &egui::Context) { + ctx.data_mut(|m| m.remove::(drag_id())); +} + +#[derive(Clone, Copy)] +struct Ring { + center: Vec2, + u: Vec2, + v: Vec2, + depth: [f32; 2], +} + +impl Ring { + fn point(self, angle: f32) -> Vec2 { + self.center + self.u * angle.cos() + self.v * angle.sin() + } + fn tangent(self, angle: f32) -> Vec2 { + -self.u * angle.sin() + self.v * angle.cos() + } + fn phase(self, p: Vec2) -> Option { + let p = p - self.center; + let det = self.u.x * self.v.y - self.u.y * self.v.x; + // Near edge-on, an ellipse's inverse magnifies tiny pointer movements. + if det.abs() < self.u.length().max(self.v.length()).powi(2) * 0.15 { + return None; + } + let x = (p.x * self.v.y - p.y * self.v.x) / det; + let y = (p.y * self.u.x - p.x * self.u.y) / det; + (x.hypot(y) > 0.1).then(|| y.atan2(x)) + } +} + +#[derive(Clone, Copy)] +struct Geometry { + center: Pos2, + radius: f32, + rings: [Ring; 3], + xf: Xf, + bounds: vectorcraft_geom::Rect, +} + +impl Geometry { + fn new(app: &VectorcraftApp, d: &Dialog) -> Option { + let state = app.session.active()?; + let node = state.doc.node(*state.selection.objects.first()?)?; + let bounds = node.geometric_bounds()?; + let xf = Xf::new(app.canvas_rect?, app.view()?); + let options = revolve_options(&super::form::params(d)); + let center = xf.to_screen(options.rotation_origin(bounds)); + if !center.x.is_finite() || !center.y.is_finite() || !xf.rect.contains(center) { + return None; + } + let radius = (bounds.height().abs() * xf.zoom * 0.24).clamp(76.0, 116.0) as f32; + let (s, c) = (xf.rot as f32).sin_cos(); + let screen = |p: [f64; 3], scale: f32| vec2(c * p[0] as f32 - s * p[1] as f32, s * p[0] as f32 + c * p[1] as f32) * radius * scale; + let rings = std::array::from_fn(|i| { + let [u, v] = options.rotation_planes()[i]; + let scale = if i == 2 { 1.12 } else { 1.0 }; + Ring { center: Vec2::ZERO, u: screen(u, scale), v: screen(v, scale), depth: [u[2] as f32, v[2] as f32] } + }); + Some(Self { center, radius, rings, xf, bounds }) + } + + fn hit(self, p: Pos2) -> Option<(usize, f32)> { + let mut best = None; + let mut distance = 9.0; + for (axis, ring) in self.rings.iter().enumerate() { + for i in 0..SAMPLES { + let a = i as f32 * std::f32::consts::TAU / SAMPLES as f32; + let b = a + std::f32::consts::TAU / SAMPLES as f32; + let (start, end) = (self.center + ring.point(a), self.center + ring.point(b)); + let line = end - start; + let t = ((p - start).dot(line) / line.length_sq().max(1e-6)).clamp(0.0, 1.0); + let d = p.distance(start + line * t); + if d < distance { + distance = d; + best = Some((axis, a + (b - a) * t)); + } + } + } + best + } + + fn light_rings(self, options: Revolve) -> [Ring; 2] { + let (s, c) = (self.xf.rot as f32).sin_cos(); + let screen = |p: [f64; 3]| vec2(c * p[0] as f32 - s * p[1] as f32, s * p[0] as f32 + c * p[1] as f32) * self.radius * 1.4; + options.light_orbits().map(|[center, u, v]| Ring { center: screen(center), u: screen(u), v: screen(v), depth: [u[2] as f32, v[2] as f32] }) + } + + fn light_labels(self) -> [Rect; 2] { + [ + Rect::from_center_size(self.center + vec2(0.0, self.radius * 1.4 + 24.0), vec2(88.0, 26.0)), + Rect::from_center_size(self.center - vec2(self.radius * 1.4 + 32.0, 0.0), vec2(78.0, 26.0)), + ] + } + + fn light_hit(self, p: Pos2, options: Revolve) -> Option { + if let Some(axis) = self.light_labels().iter().position(|r| r.contains(p)) { + return Some(axis); + } + let mut best = None; + let mut distance = 9.0; + for (axis, ring) in self.light_rings(options).iter().enumerate() { + let (start, span) = if axis == 0 { (0.0, std::f32::consts::TAU) } else { (-std::f32::consts::FRAC_PI_2, std::f32::consts::PI) }; + for i in 0..SAMPLES { + let a = start + i as f32 * span / SAMPLES as f32; + let (from, to) = (self.center + ring.point(a), self.center + ring.point(a + span / SAMPLES as f32)); + let line = to - from; + let t = ((p - from).dot(line) / line.length_sq().max(1e-6)).clamp(0.0, 1.0); + let d = p.distance(from + line * t); + if d < distance { + distance = d; + best = Some(axis); + } + } + } + best + } +} + +#[derive(Clone, Copy)] +enum Mode { + Ring { axis: usize, ring: Ring, phase: f32, degrees: f64 }, + Orbit, + Light, + LightRing { axis: usize }, +} + +#[derive(Clone)] +struct Drag { + mode: Mode, + geometry: Geometry, + last: Pos2, + origin: Pos2, + fields: serde_json::Map, +} + +fn wrap(n: f64, snap: bool) -> f64 { + let n = if snap { (n / 15.0).round() * 15.0 } else { n }; + (n + 180.0).rem_euclid(360.0) - 180.0 +} + +pub(super) fn show(app: &VectorcraftApp, ctx: &egui::Context, d: &mut Dialog) { + let light_mode = lighting(d); + if !d.bool("preview") || (light_mode && !d.bool("shade")) { + clear(ctx); + return; + } + let Some(geometry) = Geometry::new(app, d) else { return }; + let geometry = ctx.data(|m| m.get_temp::(drag_id())).map_or(geometry, |drag| drag.geometry); + let mut rect = Rect::from_center_size(geometry.center, Vec2::splat(geometry.radius * 3.0 + 48.0)); + if light_mode { + for label in geometry.light_labels() { + rect = rect.union(label.expand(2.0)); + } + } + egui::Area::new(Id::new("revolve-gizmo-area")).order(egui::Order::Foreground).fixed_pos(rect.min).movable(false).show(ctx, |ui| { + ui.set_clip_rect(geometry.xf.rect); + let response = ui.allocate_rect(rect.intersect(geometry.xf.rect), Sense::drag()); + let options = revolve_options(&super::form::params(d)); + let light = light_position(geometry, options); + let hovered = response + .hovered() + .then(|| response.hover_pos()) + .flatten() + .and_then(|p| if light_mode { geometry.light_hit(p, options).map(|axis| (axis, 0.0)) } else { geometry.hit(p) }); + if response.drag_started() + && let Some(origin) = ui.input(|i| i.pointer.press_origin()) + { + let mode = if light_mode { + if origin.distance(light) <= 18.0 { + Mode::Light + } else if let Some(axis) = geometry.light_hit(origin, options) { + Mode::LightRing { axis } + } else { + Mode::Light + } + } else if origin.distance(geometry.center) <= 14.0 { + Mode::Orbit + } else if let Some((axis, phase)) = geometry.hit(origin) { + Mode::Ring { axis, ring: geometry.rings[axis], phase, degrees: 0.0 } + } else { + Mode::Orbit + }; + ui.ctx().memory_mut(|m| m.stop_text_input()); + ui.data_mut(|m| m.insert_temp(drag_id(), Drag { mode, geometry, last: origin, origin, fields: d.fields.clone() })); + } + let mut drag = ui.data(|m| m.get_temp::(drag_id())); + if let Some(current) = &mut drag { + if ui.input_mut(|i| i.consume_key(i.modifiers, egui::Key::Escape)) { + d.fields = current.fields.clone(); + d.fields.remove(super::form::PREVIEWED); + ui.data_mut(|m| m.remove::(drag_id())); + drag = None; + } else if let Some(p) = response.interact_pointer_pos() { + let snap = ui.input(|i| i.modifiers.shift); + match &mut current.mode { + Mode::Ring { axis, ring, phase, degrees } => { + let step = if let Some(next) = ring.phase(p - geometry.center) { + let step = (next - *phase + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI; + *phase = next; + step + } else { + let tangent = ring.tangent(*phase); + let step = (p - current.last).dot(tangent) / tangent.length_sq().max(144.0); + *phase += step; + step + }; + *degrees += f64::from(step.to_degrees()); + if let Some(key) = KEYS.get(*axis) { + let start = current.fields.get(*key).and_then(|v| v.as_f64()).unwrap_or(0.0); + d.fields.insert((*key).into(), json!(wrap(start + *degrees, snap))); + } + } + Mode::Orbit => { + let delta = geometry.xf.delta_to_doc(p - current.origin) * geometry.xf.zoom; + for (key, delta) in [("rotationX", delta.y * 0.5), ("rotationY", delta.x * 0.5)] { + let start = current.fields.get(key).and_then(|v| v.as_f64()).unwrap_or(0.0); + d.fields.insert(key.into(), json!(wrap(start + delta, snap))); + } + } + Mode::Light => { + let start = revolve_options(&serde_json::Value::Object(current.fields.clone())); + let projected = light_position(geometry, start) + (p - current.origin); + let point = geometry.xf.delta_to_doc(projected - geometry.center) * (geometry.xf.zoom / f64::from(geometry.radius * 1.4)); + let [azimuth, elevation] = start.light_at_on_side(Point::new(point.x, point.y)); + d.fields.insert("lightAzimuth".into(), json!(wrap(azimuth, snap))); + d.fields.insert( + "lightElevation".into(), + json!((if snap { (elevation / 15.0).round() * 15.0 } else { elevation }).clamp(-90.0, 90.0)), + ); + } + Mode::LightRing { axis } => { + let delta = geometry.xf.delta_to_doc(p - current.origin) * geometry.xf.zoom; + let start = revolve_options(&serde_json::Value::Object(current.fields.clone())); + if *axis == 0 { + d.fields.insert("lightAzimuth".into(), json!(wrap(start.light_azimuth + delta.x * 0.6, snap))); + } else { + let elevation = start.light_elevation - delta.y * 0.6; + d.fields.insert( + "lightElevation".into(), + json!((if snap { (elevation / 15.0).round() * 15.0 } else { elevation }).clamp(-90.0, 90.0)), + ); + } + } + } + current.last = p; + ui.data_mut(|m| m.insert_temp(drag_id(), current.clone())); + } + } + if response.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + ui.data_mut(|m| m.remove::(drag_id())); + } + if response.hovered() || drag.is_some() { + ui.ctx().set_cursor_icon(if drag.is_some() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); + } + let active = match drag.as_ref().map(|d| d.mode) { + Some(Mode::Ring { axis, .. }) => Some(axis), + Some(Mode::LightRing { axis }) => Some(axis), + _ => hovered.map(|h| h.0), + }; + paint(ui, d, Geometry::new(app, d).unwrap_or(geometry), active, drag.as_ref()); + response.on_hover_text(hint(d)); + }); +} + +fn light_position(g: Geometry, options: Revolve) -> Pos2 { + let [x, y, _] = options.light_direction(); + let (s, c) = (g.xf.rot as f32).sin_cos(); + g.center + vec2(c * x as f32 - s * y as f32, s * x as f32 + c * y as f32) * g.radius * 1.4 +} + +fn paint(ui: &egui::Ui, d: &Dialog, g: Geometry, active: Option, drag: Option<&Drag>) { + if lighting(d) { + paint_light(ui, d, g, active, drag); + return; + } + let t = Tokens::get(ui.ctx()); + let painter = ui.painter(); + let colors = [t.axis_x, t.axis_y, t.axis_z]; + for (axis, ring) in g.rings.iter().enumerate() { + let color = if active == Some(axis) { t.warning } else { colors[axis] }; + for i in 0..SAMPLES { + let angle = i as f32 * std::f32::consts::TAU / SAMPLES as f32; + let behind = ring.depth[0] * angle.cos() + ring.depth[1] * angle.sin() < -0.05; + if behind && i % 3 == 0 { + continue; + } + let points = [g.center + ring.point(angle), g.center + ring.point(angle + std::f32::consts::TAU / SAMPLES as f32)]; + painter.line_segment(points, Stroke::new(4.0, t.input.gamma_multiply(0.55))); + painter.line_segment( + points, + Stroke::new(if active == Some(axis) { 2.5 } else { 1.75 }, color.gamma_multiply(if behind { 0.55 } else { 1.0 })), + ); + } + // Letters make the axes distinguishable without relying on colour alone. + let phase = match axis { + 0 => 0.75, + 1 => 2.25, + _ => 4.7, + }; + let p = g.center + ring.point(phase); + painter.circle_filled(p, 9.0, t.panel); + painter.text(p, Align2::CENTER_CENTER, ["X", "Y", "Z"][axis], FontId::proportional(11.0), color); + } + painter.circle(g.center, 8.0, t.panel.gamma_multiply(0.8), Stroke::new(1.5, t.text_dim)); + painter.line_segment([g.center - vec2(4.0, 0.0), g.center + vec2(4.0, 0.0)], Stroke::new(1.0, t.text_dim)); + painter.line_segment([g.center - vec2(0.0, 4.0), g.center + vec2(0.0, 4.0)], Stroke::new(1.0, t.text_dim)); + let options = revolve_options(&super::form::params(d)); + if d.bool("__showAxis") { + painter.line_segment(options.projected_axis(g.bounds).map(|p| g.xf.to_screen(p)), Stroke::new(1.0, t.warning)); + } + if let Some(Drag { mode: Mode::Ring { axis, .. }, .. }) = drag + && let Some(key) = KEYS.get(*axis) + { + let text = format!("{} {:.1}°", ["X", "Y", "Z"].get(*axis).unwrap_or(&""), d.f64(key, 0.0)); + let pos = g.center + vec2(0.0, g.radius * 1.12 + 18.0); + let galley = painter.layout_no_wrap(text, FontId::proportional(12.0), t.text_strong); + painter.rect_filled(Rect::from_center_size(pos, galley.size() + vec2(14.0, 8.0)), 4.0, t.panel); + painter.galley(pos - galley.size() * 0.5, galley, t.text_strong); + } +} + +fn paint_light(ui: &egui::Ui, d: &Dialog, g: Geometry, active: Option, drag: Option<&Drag>) { + let t = Tokens::get(ui.ctx()); + let painter = ui.painter(); + let options = revolve_options(&super::form::params(d)); + let radius = g.radius * 1.4; + painter.circle_stroke(g.center, radius, Stroke::new(1.0, t.text_dim.gamma_multiply(0.3))); + let labels = g.light_labels(); + for (axis, ring) in g.light_rings(options).iter().enumerate() { + let (start, span) = if axis == 0 { (0.0, std::f32::consts::TAU) } else { (-std::f32::consts::FRAC_PI_2, std::f32::consts::PI) }; + for i in 0..SAMPLES { + let a = start + i as f32 * span / SAMPLES as f32; + let behind = ring.depth[0] * a.cos() + ring.depth[1] * a.sin() < -0.05; + if behind && i % 3 == 0 { + continue; + } + let points = [g.center + ring.point(a), g.center + ring.point(a + span / SAMPLES as f32)]; + painter.line_segment(points, Stroke::new(4.0, t.panel.gamma_multiply(0.4))); + painter.line_segment( + points, + Stroke::new(if active == Some(axis) { 2.5 } else { 1.5 }, t.warning.gamma_multiply(if behind { 0.45 } else { 0.85 })), + ); + } + let label = labels[axis]; + painter.rect_filled(label, 4.0, if active == Some(axis) { t.hover } else { t.panel }); + let text = if axis == 0 { tl!("Direction") } else { tl!("Elevation") }; + painter.text(label.center(), Align2::CENTER_CENTER, text, FontId::proportional(11.0), t.warning); + } + let p = light_position(g, options); + let delta = g.center - p; + if delta.length() > 24.0 { + let direction = delta.normalized(); + let normal = vec2(-direction.y, direction.x); + let tip = g.center - direction * 12.0; + painter.line_segment([p, tip], Stroke::new(1.25, t.warning.gamma_multiply(0.6))); + painter.line_segment([tip, tip - direction * 8.0 + normal * 4.0], Stroke::new(1.25, t.warning)); + painter.line_segment([tip, tip - direction * 8.0 - normal * 4.0], Stroke::new(1.25, t.warning)); + } + painter.circle(g.center, 4.0, t.panel, Stroke::new(1.0, t.text_dim)); + // An original sun handle. A back light is hollow; the guide's back half is dashed. + let front = options.light_direction()[2] >= 0.0; + painter.circle(p, 9.0, if front { t.warning } else { t.panel }, Stroke::new(2.0, t.warning)); + for i in 0..8 { + let angle = i as f32 * std::f32::consts::TAU / 8.0; + let direction = vec2(angle.cos(), angle.sin()); + painter.line_segment([p + direction * 13.0, p + direction * 17.0], Stroke::new(1.5, t.warning)); + } + let name = crate::i18n::tr_ctx(crate::i18n::current(), "lighting", "Light"); + let galley = painter.layout_no_wrap(name.to_string(), FontId::proportional(11.0), t.warning); + let name_pos = p - vec2(galley.size().x * 0.5, 25.0 + galley.size().y); + painter.rect_filled(Rect::from_min_size(name_pos - vec2(4.0, 2.0), galley.size() + vec2(8.0, 4.0)), 3.0, t.panel); + painter.galley(name_pos, galley, t.warning); + if drag.is_some() { + let text = format!("{} {:.1}° {} {:.1}°", tl!("Direction"), d.f64("lightAzimuth", -45.0), tl!("Elevation"), d.f64("lightElevation", 45.0)); + let galley = painter.layout_no_wrap(text, FontId::proportional(11.5), t.text_strong); + let pos = g.center + vec2(0.0, radius + 56.0); + painter.rect_filled(Rect::from_center_size(pos, galley.size() + vec2(12.0, 6.0)), 4.0, t.panel); + painter.galley(pos - galley.size() * 0.5, galley, t.text_strong); + } +} + +#[cfg(test)] +#[path = "tests_revolve_gizmo.rs"] +mod tests; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 14358684..f9534577 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -228,6 +228,26 @@ fn effect_dialog_applies_to_its_appearance_item() { assert!(n.appearance.effects.is_empty() && n.appearance.items[0].effects().is_empty()); } +#[test] +fn revolve_dialog_preview_cancel_confirm_and_undo() { + let mut app = app(); + app.run("path.create", json!({"d":"M150 40 L180 60 L180 140 L150 160"})).unwrap(); + app.run("select.all", json!({})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + app.run("effect.dialog", json!({"effect":"threeD.revolve","item":null})).unwrap(); + frame(&mut app, Default::default()); + assert!(app.session.in_interaction()); + cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + app.run("effect.dialog", json!({"effect":"threeD.revolve","item":null})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("angle".into(), json!(180)); + frame(&mut app, Default::default()); + confirm(&mut app).unwrap(); + assert_ne!(app.session.doc().unwrap().doc, before); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); +} + #[test] fn dialogs_are_as_tall_as_their_content() { // The save prompt, shown after a taller dialog, is as tall as its text and buttons: no empty diff --git a/crates/ui-egui/src/dialogs/tests_revolve.rs b/crates/ui-egui/src/dialogs/tests_revolve.rs new file mode 100644 index 00000000..554a4fe5 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_revolve.rs @@ -0,0 +1,201 @@ +//! Real pointer interaction with Revolve's compact controls. +use super::*; +use crate::{dialogs, theme}; +use egui::{Event, PointerButton, Rect, Vec2, pos2}; +use vectorcraft_engine::Session; + +struct Harness { + app: VectorcraftApp, + ctx: egui::Context, + screen: Vec2, + texts: Vec<(String, Rect)>, +} + +impl Harness { + fn new(screen: Vec2) -> Self { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":300,"height":300})).unwrap(); + app.run("path.create", json!({"d":"M150 40 L180 60 L180 140 L150 160"})).unwrap(); + app.run("paint.setFill", json!({"color":"#338ad6"})).unwrap(); + app.run("effect.dialog", json!({"effect":REVOLVE,"item":null})).unwrap(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + Self { app, ctx, screen, texts: vec![] } + } + + fn frame(&mut self, events: Vec) { + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, self.screen)), events, ..Default::default() }; + let mut out = self.ctx.run_ui(input, |ui| dialogs::show(&mut self.app, ui.ctx())); + out.textures_delta.clear(); + fn collect(shape: &egui::Shape, clip: Rect, texts: &mut Vec<(String, Rect)>) { + match shape { + egui::Shape::Text(t) if clip.contains_rect(t.visual_bounding_rect()) => { + texts.push((t.galley.text().to_string(), t.visual_bounding_rect())) + } + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, clip, texts)), + _ => {} + } + } + self.texts.clear(); + out.shapes.iter().for_each(|s| collect(&s.shape, s.clip_rect, &mut self.texts)); + } + + fn settle(&mut self) { + for _ in 0..5 { + self.frame(vec![]); + } + } + + fn rect(&self, text: &str) -> Rect { + self.texts.iter().find(|(s, _)| s == text).map(|(_, r)| *r).unwrap_or_else(|| panic!("missing text {text:?}")) + } + + fn response(&self, id: impl std::hash::Hash + std::fmt::Debug) -> Rect { + self.ctx.read_response(Id::new(id)).unwrap().rect + } + + fn click(&mut self, p: Pos2) { + self.frame(vec![Event::PointerMoved(p), button(p, true)]); + self.frame(vec![button(p, false)]); + self.settle(); + } + + fn drag(&mut self, p: Pos2, delta: Vec2) { + self.frame(vec![Event::PointerMoved(p), button(p, true)]); + for i in 1..=5 { + self.frame(vec![Event::PointerMoved(p + delta * (i as f32 / 5.0))]); + } + self.frame(vec![button(p + delta, false)]); + self.settle(); + } + + fn value(&self, key: &str) -> f64 { + self.app.ui.dialog.as_ref().unwrap().f64(key, 0.0) + } +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() } +} + +#[test] +fn values_labels_sliders_and_reset_dots_change_the_live_effect_in_one_undo_step() { + let mut h = Harness::new(vec2(1280.0, 900.0)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + assert!(!h.rect("Advanced").is_negative(), "Advanced is visible without scrolling in a normal window"); + let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap(); + assert!(panel.width() < 420.0 && panel.height() < 540.0, "compact controls: {panel:?}"); + let label = h.rect("Rotation X"); + h.drag(pos2(label.left() + LABEL_W + 6.0 + VALUE_W / 2.0, label.center().y), vec2(40.0, 0.0)); + assert!(h.value("rotationX") > 0.0, "the value itself drags"); + let label = h.rect("Rotation Y"); + h.drag(label.center(), vec2(40.0, 0.0)); + assert!(h.value("rotationY") > 0.0, "the label scrubs"); + let label = h.rect("Revolve angle"); + h.click(pos2(label.left() + LABEL_W + VALUE_W + 24.0, label.center().y)); + assert!(h.value("angle") < 180.0, "the slider changes the sweep"); + h.click(h.response(("revolve-reset", "rotationX")).center()); + assert_eq!(h.value("rotationX"), 0.0); + assert!(h.value("angle") < 180.0, "a dot resets only its own setting"); + let node = h.app.session.doc().unwrap().doc.node(h.app.session.doc().unwrap().selection.objects[0]).unwrap(); + assert_eq!(node.appearance.effects[0].params["angle"], json!(h.value("angle"))); + h.click(h.response("revolve-reset-all").center()); + let defaults = vectorcraft_effects::default_params(REVOLVE).unwrap(); + let dialog = h.app.ui.dialog.as_ref().unwrap(); + for (key, value) in defaults.as_object().unwrap() { + assert_eq!(dialog.fields.get(key), Some(value), "reset all: {key}"); + } + dialogs::confirm(&mut h.app).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + +#[test] +fn preview_off_changes_fields_without_changing_the_document() { + let mut h = Harness::new(vec2(1280.0, 900.0)); + h.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(false)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + h.drag(h.rect("Rotation Y").center(), vec2(40.0, 0.0)); + assert!(h.value("rotationY") > 0.0); + assert_eq!(h.app.session.doc().unwrap().doc, before); + dialogs::cancel(&mut h.app); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + +#[test] +fn command_driven_fields_preview_and_small_windows_keep_the_buttons_reachable() { + let mut h = Harness::new(vec2(640.0, 480.0)); + h.settle(); + let dialog = h.app.ui.dialog.as_mut().unwrap(); + dialog.fields.insert("angle".into(), json!(180)); + h.settle(); + let state = h.app.session.doc().unwrap(); + let node = state.doc.node(state.selection.objects[0]).unwrap(); + assert_eq!(node.appearance.effects[0].params["angle"], json!(180)); + let screen = Rect::from_min_size(Pos2::ZERO, h.screen); + assert!(screen.contains_rect(h.rect("OK")) && screen.contains_rect(h.rect("Cancel"))); + dialogs::cancel(&mut h.app); + assert!(!h.app.session.in_interaction()); +} + +#[test] +fn lighting_tab_shows_light_fields_and_keeps_the_dialog_compact() { + let mut h = Harness::new(vec2(1280.0, 900.0)); + h.settle(); + h.click(h.rect("Lighting").center()); + assert_eq!(h.app.ui.dialog.as_ref().unwrap().str("__gizmo"), "light"); + for label in ["Shading", "Direction", "Elevation", "Intensity", "Ambient"] { + assert!(Rect::from_min_size(Pos2::ZERO, h.screen).contains_rect(h.rect(label))); + } + let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap(); + assert!(panel.width() < 420.0 && panel.height() < 460.0, "compact lighting controls: {panel:?}"); + h.drag(h.rect("Direction").center(), vec2(24.0, 0.0)); + assert_ne!(h.value("lightAzimuth"), -45.0); + h.click(h.response(("revolve-reset", "lightAzimuth")).center()); + assert_eq!(h.value("lightAzimuth"), -45.0); + h.click(h.rect("Rotation").center()); + assert_eq!(h.app.ui.dialog.as_ref().unwrap().str("__gizmo"), "rotation"); + assert!(!h.rect("Rotation X").is_negative()); +} + +#[test] +fn advanced_visibility_option_is_saved_resettable_and_leaves_preview_geometry_complete() { + let mut h = Harness::new(vec2(1280.0, 1000.0)); + h.settle(); + h.click(h.rect("Advanced").center()); + let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap(); + h.frame(vec![ + Event::PointerMoved(panel.center()), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -220.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }, + ]); + h.settle(); + let before = vectorcraft_effects::revolve_art({ + let state = h.app.session.doc().unwrap(); + state.doc.node(state.selection.objects[0]).unwrap() + }) + .unwrap(); + let checkbox = h.rect("Keep visible surfaces only when expanding"); + h.click(checkbox.center()); + assert!(!h.app.ui.dialog.as_ref().unwrap().bool("expandVisibleOnly")); + let state = h.app.session.doc().unwrap(); + let node = state.doc.node(state.selection.objects[0]).unwrap(); + assert_eq!(node.appearance.effects[0].params["expandVisibleOnly"], json!(false)); + assert_eq!(vectorcraft_effects::revolve_art(node), Some(before)); + h.click(h.response(("revolve-reset", "expandVisibleOnly")).center()); + assert!(h.app.ui.dialog.as_ref().unwrap().bool("expandVisibleOnly")); + h.click(h.rect("Keep visible surfaces only when expanding").center()); + h.click(h.response("revolve-reset-all").center()); + assert!(h.app.ui.dialog.as_ref().unwrap().bool("expandVisibleOnly")); + dialogs::confirm(&mut h.app).unwrap(); + let state = h.app.session.doc().unwrap(); + let loaded = vectorcraft_format::load(&vectorcraft_format::save(&state.doc, false)).unwrap(); + assert_eq!(loaded.node(state.selection.objects[0]).unwrap().appearance.effects[0].params["expandVisibleOnly"], json!(true)); +} diff --git a/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs b/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs new file mode 100644 index 00000000..02c3fa3e --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs @@ -0,0 +1,250 @@ +//! Pointer gestures exercise the real artboard overlay and command preview, including rollback. +use super::*; +use crate::{dialogs, theme}; +use egui::{Event, Modifiers, PointerButton}; +use vectorcraft_engine::Session; + +struct Harness { + app: VectorcraftApp, + ctx: egui::Context, +} + +impl Harness { + fn new() -> Self { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":300,"height":300})).unwrap(); + app.run("path.create", json!({"d":"M150 40 L180 60 L180 140 L150 160"})).unwrap(); + app.run("paint.setFill", json!({"color":"#338ad6"})).unwrap(); + app.run("effect.dialog", json!({"effect":REVOLVE,"item":null})).unwrap(); + app.canvas_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1280.0, 900.0))); + let v = app.view_mut().unwrap(); + v.center = Point::new(270.0, 150.0); + v.zoom = 2.0; + v.fitted = true; + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let mut h = Self { app, ctx }; + h.settle(); + h + } + + fn frame(&mut self, events: Vec, modifiers: Modifiers) { + let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1280.0, 900.0))), events, ..Default::default() }; + let mut out = self.ctx.run_ui(input, |ui| { + crate::shortcuts::handle(&mut self.app, ui.ctx()); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::show(&mut self.app, ui)); + dialogs::show(&mut self.app, ui.ctx()); + }); + out.textures_delta.clear(); + } + + fn settle(&mut self) { + for _ in 0..5 { + self.frame(vec![], Modifiers::NONE); + } + } + + fn geometry(&self) -> Geometry { + Geometry::new(&self.app, self.app.ui.dialog.as_ref().unwrap()).unwrap() + } + + fn value(&self, key: &str) -> f64 { + self.app.ui.dialog.as_ref().unwrap().f64(key, 0.0) + } + + fn start(&mut self, p: Pos2, modifiers: Modifiers) { + self.frame(vec![Event::PointerMoved(p)], modifiers); + self.frame(vec![button(p, true, modifiers)], modifiers); + } + + fn release(&mut self, p: Pos2, modifiers: Modifiers) { + self.frame(vec![button(p, false, modifiers)], modifiers); + self.settle(); + } + + fn ring_drag(&mut self, axis: usize, modifiers: Modifiers) { + let g = self.geometry(); + let ring = g.rings[axis]; + // Choose a visible point belonging only to this ring, away from crossings. + let phase = (0..SAMPLES) + .map(|i| i as f32 * std::f32::consts::TAU / SAMPLES as f32) + .find(|a| g.hit(g.center + ring.point(*a)).is_some_and(|h| h.0 == axis) && ring.tangent(*a).length() > 40.0) + .unwrap(); + let from = g.center + ring.point(phase); + self.start(from, modifiers); + let mut p = from; + for i in 1..=8 { + p = g.center + ring.point(phase + i as f32 * 0.045); + self.frame(vec![Event::PointerMoved(p)], modifiers); + } + self.release(p, modifiers); + } +} + +fn button(pos: Pos2, pressed: bool, modifiers: Modifiers) -> Event { + Event::PointerButton { pos, pressed, button: PointerButton::Primary, modifiers } +} +fn escape() -> Event { + Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE } +} + +#[test] +fn each_ring_changes_only_its_rotation_field_and_cancel_restores_the_source() { + for (axis, key) in KEYS.iter().enumerate() { + let mut h = Harness::new(); + let before = h.app.session.active().unwrap().interaction.as_ref().unwrap().doc.clone(); + let original = KEYS.map(|key| h.value(key)); + h.ring_drag(axis, Modifiers::NONE); + for (k, key) in KEYS.iter().enumerate() { + if k == axis { + assert!(h.value(key) > original[k] + 10.0, "axis {axis}, {}", h.value(key)); + } else { + assert_eq!(h.value(key), original[k]); + } + } + let state = h.app.session.active().unwrap(); + let node = state.doc.node(state.selection.objects[0]).unwrap(); + assert_eq!(node.path_data(), before.node(node.id).unwrap().path_data(), "ring drags must not move source anchors"); + assert_eq!(node.appearance.effects[0].params[*key], json!(h.value(key))); + dialogs::cancel(&mut h.app); + assert_eq!(h.app.session.doc().unwrap().doc, before); + } +} + +#[test] +fn ring_drag_snaps_and_confirm_is_one_undo_step() { + let mut h = Harness::new(); + let before = h.app.session.active().unwrap().interaction.as_ref().unwrap().doc.clone(); + h.ring_drag(2, Modifiers::SHIFT); + assert!(h.value("rotationZ") > 0.0); + assert_eq!(h.value("rotationZ") % 15.0, 0.0); + dialogs::confirm(&mut h.app).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + +#[test] +fn free_orbit_and_light_drag_update_the_canvas_and_escape_restores_only_the_gesture() { + let mut h = Harness::new(); + let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap()); + let center = h.geometry().center; + h.start(center, Modifiers::NONE); + let p = center + vec2(50.0, 30.0); + h.frame(vec![Event::PointerMoved(p)], Modifiers::NONE); + assert_ne!(h.value("rotationX"), before["rotationX"].as_f64().unwrap()); + assert_ne!(h.value("rotationY"), before["rotationY"].as_f64().unwrap()); + h.frame(vec![escape()], Modifiers::NONE); + h.release(p, Modifiers::NONE); + assert!(h.app.ui.dialog.is_some()); + assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before); + let state = h.app.session.active().unwrap(); + let node = state.doc.node(state.selection.objects[0]).unwrap(); + assert_eq!(node.appearance.effects[0].params["rotationX"], before["rotationX"]); + assert_eq!(node.appearance.effects[0].params["rotationY"], before["rotationY"]); + h.app.ui.dialog.as_mut().unwrap().fields.insert("__gizmo".into(), json!("light")); + h.settle(); + let light = light_position(h.geometry(), revolve_options(&before)); + h.start(light, Modifiers::NONE); + let p = light + vec2(20.0, 25.0); + h.frame(vec![Event::PointerMoved(p)], Modifiers::NONE); + h.release(p, Modifiers::NONE); + assert_ne!(h.value("lightAzimuth"), -45.0); + assert_ne!(h.value("lightElevation"), 45.0); + h.frame(vec![escape()], Modifiers::NONE); + assert!(h.app.ui.dialog.is_none() && !h.app.session.in_interaction()); +} + +#[test] +fn preview_off_has_no_gizmo_and_edge_on_rings_remain_finite() { + let mut h = Harness::new(); + let d = h.app.ui.dialog.as_mut().unwrap(); + d.fields.insert("rotationX".into(), json!(0.0)); + d.fields.insert("rotationY".into(), json!(0.0)); + d.fields.insert("rotationZ".into(), json!(0.0)); + h.settle(); + h.ring_drag(0, Modifiers::NONE); + assert!(h.value("rotationX").is_finite() && h.value("rotationX").abs() > 1.0); + h.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(false)); + h.settle(); + assert!(!h.app.session.in_interaction()); + let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap()); + h.ring_drag(2, Modifiers::NONE); + assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before); +} + +#[test] +fn light_rings_adjust_only_their_angle_and_snap_without_rotating_the_object() { + for (axis, key) in ["lightAzimuth", "lightElevation"].iter().enumerate() { + let mut h = Harness::new(); + h.app.ui.dialog.as_mut().unwrap().fields.insert("__gizmo".into(), json!("light")); + h.settle(); + let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap()); + let from = h.geometry().light_labels()[axis].center(); + h.start(from, Modifiers::SHIFT); + let delta = if axis == 0 { vec2(68.0, 0.0) } else { vec2(0.0, -43.0) }; + h.frame(vec![Event::PointerMoved(from + delta)], Modifiers::SHIFT); + h.release(from + delta, Modifiers::SHIFT); + assert_ne!(h.value(key), before[*key].as_f64().unwrap()); + assert_eq!(h.value(key) % 15.0, 0.0); + for unchanged in ["rotationX", "rotationY", "rotationZ", "lightIntensity", "ambient"] { + assert_eq!(h.value(unchanged), before[unchanged].as_f64().unwrap()); + } + let other = if axis == 0 { "lightElevation" } else { "lightAzimuth" }; + assert_eq!(h.value(other), before[other].as_f64().unwrap()); + let state = h.app.session.active().unwrap(); + let node = state.doc.node(state.selection.objects[0]).unwrap(); + assert_eq!(node.appearance.effects[0].params[*key], json!(h.value(key))); + } +} + +#[test] +fn light_handle_preserves_the_back_side_and_escape_cancel_and_undo_restore_changes() { + let mut h = Harness::new(); + let original = h.app.session.active().unwrap().interaction.as_ref().unwrap().doc.clone(); + let d = h.app.ui.dialog.as_mut().unwrap(); + d.fields.insert("__gizmo".into(), json!("light")); + d.fields.insert("lightAzimuth".into(), json!(135.0)); + h.settle(); + let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap()); + let light = light_position(h.geometry(), revolve_options(&before)); + h.start(light, Modifiers::NONE); + let to = light + vec2(-15.0, 12.0); + h.frame(vec![Event::PointerMoved(to)], Modifiers::NONE); + assert!(revolve_options(&super::super::form::params(h.app.ui.dialog.as_ref().unwrap())).light_direction()[2] < 0.0); + assert_ne!(h.value("lightElevation"), 45.0); + h.frame(vec![escape()], Modifiers::NONE); + h.release(to, Modifiers::NONE); + assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before); + let state = h.app.session.active().unwrap(); + assert_eq!(state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0].params["lightAzimuth"], before["lightAzimuth"]); + // A second gesture is kept by OK, and the whole dialog is one undo step. + let from = h.geometry().light_labels()[0].center(); + h.start(from, Modifiers::NONE); + h.frame(vec![Event::PointerMoved(from + vec2(35.0, 0.0))], Modifiers::NONE); + h.release(from + vec2(35.0, 0.0), Modifiers::NONE); + dialogs::confirm(&mut h.app).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, original); +} + +#[test] +fn light_elevation_is_bounded_and_shading_off_disables_the_light_gizmo() { + let mut h = Harness::new(); + h.app.ui.dialog.as_mut().unwrap().fields.insert("__gizmo".into(), json!("light")); + h.settle(); + let from = h.geometry().light_labels()[1].center(); + h.start(from, Modifiers::NONE); + h.frame(vec![Event::PointerMoved(from - vec2(0.0, 400.0))], Modifiers::NONE); + h.release(from - vec2(0.0, 400.0), Modifiers::NONE); + assert_eq!(h.value("lightElevation"), 90.0); + h.app.ui.dialog.as_mut().unwrap().fields.insert("shade".into(), json!(false)); + h.settle(); + let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap()); + let from = h.geometry().light_labels()[0].center(); + h.start(from, Modifiers::NONE); + h.frame(vec![Event::PointerMoved(from + vec2(40.0, 0.0))], Modifiers::NONE); + h.release(from + vec2(40.0, 0.0), Modifiers::NONE); + assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before); +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bbcc8258..f02a85f8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -694,3 +694,32 @@ Ocean Ripple… Vlnky na hladině… Rename Data Set Přejmenovat sadu dat Rename Variable Přejmenovat proměnnou + +# Interactive Revolve controls. + Lighting Osvětlení + Revolve angle Úhel otočení + Axis side Strana osy + Rotation X Otočení X + Rotation Y Otočení Y + Rotation Z Otočení Z + Shading Stínování + Direction Směr + Elevation Výška světla + Intensity Intenzita + Ambient Okolní osvětlení + Segments Segmenty + Show axis Zobrazit osu + Reset all Obnovit vše + Reset to default Obnovit výchozí hodnotu + Reset all Revolve settings to their defaults. Obnovit výchozí hodnoty všech nastavení rotačního tělesa. + Drag values or labels. Double-click a value to type. Upravte hodnoty přetažením čísel nebo popisků. Dvojím kliknutím na hodnotu ji zadáte. + Drag rings to rotate. Shift snaps to 15°. Otáčejte přetažením prstenců. Shift nastaví kroky po 15°. + Drag the light, or its rings. Shift snaps to 15°. Přetáhněte světlo nebo jeho prstence. Shift nastaví kroky po 15°. +lighting Light Světlo + Keep visible surfaces only when expanding Při rozšíření ponechat pouze viditelné povrchy + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Při rozšíření vzhledu oříznout zakrytou geometrii. Živý náhled zachová celý povrch. Průhledné výplně a povrchy překračující limity výpočtu viditelnosti zachovají všechny plochy. + Left Vlevo + Right Vpravo + Offset Odsazení + Rotation Otočení + Cancel Zrušit diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 58d4344e..ca51323e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3994,3 +3994,25 @@ theme Appearance Erscheinungsbild Define a variable first Definieren Sie zuerst eine Variable Text Text Visibility Sichtbarkeit +# 3D and Materials › Revolve: its options dialog and gizmos. + Ambient Umgebungslicht + Axis side Achsenseite + Direction Richtung + Drag rings to rotate. Shift snaps to 15°. Ziehen Sie die Ringe, um zu drehen. Mit Shift in 15°-Schritten. + Drag the light, or its rings. Shift snaps to 15°. Ziehen Sie das Licht oder seine Ringe. Mit Shift in 15°-Schritten. + Drag values or labels. Double-click a value to type. Ziehen Sie Werte oder Beschriftungen. Doppelklicken Sie auf einen Wert, um ihn einzugeben. + Elevation Höhe + Intensity Intensität + Keep visible surfaces only when expanding Beim Umwandeln nur sichtbare Flächen behalten + Lighting Beleuchtung + Reset all Alle zurücksetzen + Reset all Revolve settings to their defaults. Alle Einstellungen für „Kreiseln“ auf ihre Standardwerte zurücksetzen. + Reset to default Auf Standard zurücksetzen + Revolve angle Kreiselwinkel + Rotation X Drehung X + Rotation Y Drehung Y + Rotation Z Drehung Z + Segments Segmente + Shading Schattierung + Show axis Achse einblenden + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Entfernt bei „Aussehen umwandeln“ verdeckte Geometrie. Die Live-Vorschau behält die ganze Oberfläche. Transparente Farben und Oberflächen jenseits der Sichtbarkeitsgrenzen behalten alle Flächen. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d52cf1ef..82ed1583 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4037,3 +4037,28 @@ axis Both Ambos Define a variable first Defina primero una variable Text Texto Visibility Visibilidad + +# Revolve profile guidance. + +# Interactive Revolve controls. + Lighting Iluminación + Revolve angle Ángulo de revolución + Axis side Lado del eje + Rotation X Rotación X + Rotation Y Rotación Y + Rotation Z Rotación Z + Shading Sombreado + Direction Dirección + Elevation Elevación + Ambient Luz ambiental + Segments Segmentos + Show axis Mostrar eje + Reset all Restablecer todo + Reset to default Restablecer valor predeterminado + Reset all Revolve settings to their defaults. Restablece todos los ajustes de revolución a sus valores predeterminados. + Drag values or labels. Double-click a value to type. Arrastra los valores o las etiquetas. Haz doble clic en un valor para escribir. + Drag rings to rotate. Shift snaps to 15°. Arrastra los anillos para girar. Shift ajusta a pasos de 15°. + Drag the light, or its rings. Shift snaps to 15°. Arrastra la luz o sus anillos. Shift ajusta a pasos de 15°. +lighting Light Luz + Keep visible surfaces only when expanding Conservar solo superficies visibles al expandir + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Recorta la geometría oculta al expandir la apariencia. La vista previa conserva toda la superficie. Las pinturas transparentes y las superficies que superan los límites de visibilidad conservan todas las caras. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 76796bd1..6b6e260a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4049,3 +4049,26 @@ effect Soft Doux Define a variable first Définissez d’abord une variable Text Texte Visibility Visibilité + +# Interactive Revolve controls. + Lighting Éclairage + Revolve angle Angle de révolution + Axis side Côté de l’axe + Rotation X Rotation X + Rotation Y Rotation Y + Rotation Z Rotation Z + Shading Ombrage + Direction Direction + Elevation Élévation + Ambient Lumière ambiante + Segments Segments + Show axis Afficher l’axe + Reset all Tout réinitialiser + Reset to default Valeur par défaut + Reset all Revolve settings to their defaults. Rétablir les valeurs par défaut de tous les réglages de révolution. + Drag values or labels. Double-click a value to type. Faites glisser les valeurs ou les libellés. Double-cliquez sur une valeur pour la saisir. + Drag rings to rotate. Shift snaps to 15°. Faites glisser les anneaux pour tourner. Maj contraint par pas de 15°. + Drag the light, or its rings. Shift snaps to 15°. Faites glisser la lumière ou ses anneaux. Maj contraint par pas de 15°. + Keep visible surfaces only when expanding Conserver uniquement les surfaces visibles lors de la décomposition + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Découpe la géométrie masquée lors de la décomposition de l’aspect. L’aperçu conserve toute la surface. Les peintures transparentes et les surfaces dépassant les limites de visibilité conservent toutes les faces. +lighting Light Lumière diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a9c328e9..eccca0d4 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4039,3 +4039,28 @@ effect Regular Normale Define a variable first Definisci prima una variabile Text Testo Visibility Visibilità + +# Revolve profile guidance. + +# Interactive Revolve controls. + Lighting Illuminazione + Revolve angle Angolo di rivoluzione + Axis side Lato dell’asse + Rotation X Rotazione X + Rotation Y Rotazione Y + Rotation Z Rotazione Z + Shading Ombreggiatura + Direction Direzione + Elevation Elevazione + Ambient Luce ambientale + Segments Segmenti + Show axis Mostra asse + Reset all Ripristina tutto + Reset to default Ripristina valore predefinito + Reset all Revolve settings to their defaults. Ripristina tutte le impostazioni di rivoluzione ai valori predefiniti. + Drag values or labels. Double-click a value to type. Trascina i valori o le etichette. Fai doppio clic su un valore per digitarlo. + Drag rings to rotate. Shift snaps to 15°. Trascina gli anelli per ruotare. Shift usa incrementi di 15°. + Drag the light, or its rings. Shift snaps to 15°. Trascina la luce o i suoi anelli. Shift usa incrementi di 15°. +lighting Light Luce + Keep visible surfaces only when expanding Mantieni solo le superfici visibili durante l’espansione + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Ritaglia la geometria coperta durante Espandi aspetto. L’anteprima mantiene la superficie completa. Le tinte trasparenti e le superfici che superano i limiti di visibilità mantengono tutte le facce. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6c13d3ae..179e4b86 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4032,3 +4032,28 @@ effect Vertical 垂直 Define a variable first 先に変数を定義してください Text テキスト Visibility 表示状態 + +# Revolve profile guidance. + +# Interactive Revolve controls. + Lighting 照明 + Revolve angle 回転体の角度 + Axis side 軸の位置 + Rotation X X 軸回転 + Rotation Y Y 軸回転 + Rotation Z Z 軸回転 + Shading 陰影 + Direction 方向 + Elevation 仰角 + Ambient 環境光 + Segments 分割数 + Show axis 軸を表示 + Reset all すべてリセット + Reset to default 初期値に戻す + Reset all Revolve settings to their defaults. 回転体のすべての設定を初期値に戻します。 + Drag values or labels. Double-click a value to type. 数値やラベルをドラッグして調整。数値をダブルクリックすると入力できます。 + Drag rings to rotate. Shift snaps to 15°. リングをドラッグして回転。Shiftで15°刻みに調整。 + Drag the light, or its rings. Shift snaps to 15°. ライトやリングをドラッグ。Shiftで15°刻みに調整。 +lighting Light ライト + Keep visible surfaces only when expanding アピアランス分割時に見える面だけを保持 + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. アピアランスの分割時に隠れた部分を除去します。プレビューでは面全体を保持します。透明なペイントや可視性処理の制限を超える面は、すべての面を保持します。 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b1526a88..ec9b4d38 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2754,3 +2754,30 @@ Ocean Ripple… Ondulação oceânica… Rename Data Set Renomear conjunto de dados Rename Variable Renomear variável + +# Interactive Revolve controls. + Lighting Iluminação + Revolve angle Ângulo de giro + Axis side Lado do eixo + Rotation X Rotação X + Rotation Y Rotação Y + Rotation Z Rotação Z + Shading Sombreamento + Direction Direção + Elevation Elevação + Intensity Intensidade + Ambient Luz ambiente + Segments Segmentos + Show axis Mostrar eixo + Reset all Redefinir tudo + Reset to default Restaurar padrão + Reset all Revolve settings to their defaults. Restaurar os valores padrão de todas as configurações de revolução. + Drag values or labels. Double-click a value to type. Arraste os valores ou rótulos. Clique duas vezes em um valor para digitá-lo. + Drag rings to rotate. Shift snaps to 15°. Arraste os anéis para girar. Shift ajusta em incrementos de 15°. + Drag the light, or its rings. Shift snaps to 15°. Arraste a luz ou seus anéis. Shift ajusta em incrementos de 15°. +lighting Light Luz + Keep visible surfaces only when expanding Manter apenas as superfícies visíveis ao expandir + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Recortar a geometria coberta ao expandir a aparência. A visualização ao vivo mantém toda a superfície. Pinturas transparentes e superfícies que excedem os limites de visibilidade mantêm todas as faces. + Left Esquerda + Right Direita + Offset Deslocamento diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3fd2cfe8..a3316937 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4035,3 +4035,28 @@ axis Both Оба Define a variable first Сначала определите переменную Text Текст Visibility Видимость + +# Revolve profile guidance. + +# Interactive Revolve controls. + Lighting Освещение + Revolve angle Угол вращения профиля + Axis side Сторона оси + Rotation X Поворот X + Rotation Y Поворот Y + Rotation Z Поворот Z + Shading Затенение + Direction Направление + Elevation Высота + Ambient Фоновое освещение + Segments Сегменты + Show axis Показать ось + Reset all Сбросить всё + Reset to default Вернуть значение по умолчанию + Reset all Revolve settings to their defaults. Возвращает все настройки вращения профиля к значениям по умолчанию. + Drag values or labels. Double-click a value to type. Перетаскивайте значения или подписи. Для ввода дважды щёлкните значение. + Drag rings to rotate. Shift snaps to 15°. Перетаскивайте кольца для вращения. Shift задаёт шаг 15°. + Drag the light, or its rings. Shift snaps to 15°. Перетаскивайте источник света или кольца. Shift задаёт шаг 15°. +lighting Light Свет + Keep visible surfaces only when expanding При разборе сохранять только видимые поверхности + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Удаляет скрытую геометрию при разборе оформления. Предпросмотр сохраняет всю поверхность. Прозрачные краски и поверхности, превышающие ограничения видимости, сохраняют все грани. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8140c543..6de840be 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4020,3 +4020,26 @@ axis Both Обидві Define a variable first Спочатку визначте змінну Text Текст Visibility Видимість + +# Interactive Revolve controls. + Lighting Освітлення + Revolve angle Кут обертання + Axis side Бік осі + Rotation X Обертання X + Rotation Y Обертання Y + Rotation Z Обертання Z + Shading Затінення + Direction Напрямок + Elevation Висота + Ambient Навколишнє освітлення + Segments Сегменти + Show axis Показати вісь + Reset all Скинути все + Reset to default Відновити типове значення + Reset all Revolve settings to their defaults. Відновити типові значення всіх параметрів обертання. + Drag values or labels. Double-click a value to type. Перетягуйте значення або підписи. Двічі клацніть значення, щоб увести його. + Drag rings to rotate. Shift snaps to 15°. Перетягуйте кільця для обертання. Shift задає крок 15°. + Drag the light, or its rings. Shift snaps to 15°. Перетягуйте джерело світла або його кільця. Shift задає крок 15°. + Keep visible surfaces only when expanding Під час розгортання зберігати лише видимі поверхні + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Обрізає приховану геометрію під час розгортання оформлення. Попередній перегляд зберігає всю поверхню. Прозорі заливки та поверхні, що перевищують обмеження видимості, зберігають усі грані. +lighting Light Світло diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9bd0df0e..59a88ce3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3668,3 +3668,28 @@ effect Vertical 垂直 Define a variable first 请先定义一个变量 Text 文本 Visibility 可见性 + +# Revolve profile guidance. + +# Interactive Revolve controls. + Lighting 照明 + Revolve angle 旋转成形角度 + Axis side 轴的位置 + Rotation X X 轴旋转 + Rotation Y Y 轴旋转 + Rotation Z Z 轴旋转 + Shading 明暗处理 + Direction 方向 + Elevation 仰角 + Ambient 环境光 + Segments 分段数 + Show axis 显示轴 + Reset all 全部重置 + Reset to default 重置为默认值 + Reset all Revolve settings to their defaults. 将所有旋转成形设置重置为默认值。 + Drag values or labels. Double-click a value to type. 拖动数值或标签。双击数值即可输入。 + Drag rings to rotate. Shift snaps to 15°. 拖动圆环旋转。按住 Shift 以 15° 为步长调整。 + Drag the light, or its rings. Shift snaps to 15°. 拖动光源或圆环。按住 Shift 以 15° 为步长调整。 +lighting Light 光源 + Keep visible surfaces only when expanding 扩展时仅保留可见表面 + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. 扩展外观时裁剪被遮挡的几何区域。实时预览保留完整表面。透明绘制或超出可见性处理限制的表面保留所有面。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a4edda1d..77ac676d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3449,3 +3449,28 @@ effect Soft 柔和 Define a variable first 請先定義一個變數 Text 文字 Visibility 可見性 + +# Revolve profile guidance. + +# Interactive Revolve controls. + Lighting 照明 + Revolve angle 旋轉成形角度 + Axis side 軸的位置 + Rotation X X 軸旋轉 + Rotation Y Y 軸旋轉 + Rotation Z Z 軸旋轉 + Shading 明暗處理 + Direction 方向 + Elevation 仰角 + Ambient 環境光 + Segments 分段數 + Show axis 顯示軸 + Reset all 全部重設 + Reset to default 重設為預設值 + Reset all Revolve settings to their defaults. 將所有旋轉成形設定重設為預設值。 + Drag values or labels. Double-click a value to type. 拖曳數值或標籤。按兩下數值即可輸入。 + Drag rings to rotate. Shift snaps to 15°. 拖曳圓環旋轉。按住 Shift 以 15° 為間距調整。 + Drag the light, or its rings. Shift snaps to 15°. 拖曳光源或圓環。按住 Shift 以 15° 為間距調整。 +lighting Light 光源 + Keep visible surfaces only when expanding 展開時僅保留可見表面 + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. 展開外觀時裁剪被遮擋的幾何區域。即時預覽保留完整表面。透明繪製或超出可見性處理限制的表面保留所有面。 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e0ecb0b0..4ddfd628 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3362,6 +3362,10 @@ fn effect_menu() -> Vec { }; out.push(sub(sub_name, placeholder)); } else { + let mut items = items; + if sub_name == "3D and Materials" { + items.extend([todo("Extrude & Bevel…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")]); + } out.push(sub(sub_name, items)); } } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index dc25ceb6..3cd5716f 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -213,11 +213,15 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } if app.ui.dialog.is_some() || app.ui.palette_open { + if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) && crate::dialogs::revolve_gizmo::dragging(ctx) { + // Escape first restores this rotation gesture. The settings stay open. + return; + } if crate::shortcut_editor::is_recording(app) { return; } if ctx.input(|i| i.key_pressed(Key::Escape)) { - app.ui.dialog = None; + crate::dialogs::cancel(app); app.ui.palette_open = false; } return; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 67a6f84b..b2bf18fe 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -89,6 +89,10 @@ pub struct Tokens { pub error: Color32, /// Warning marks (a modified linked file). pub warning: Color32, + /// Rotation gizmo axes, also labelled X, Y and Z. + pub axis_x: Color32, + pub axis_y: Color32, + pub axis_z: Color32, } impl Tokens { @@ -133,6 +137,9 @@ impl Tokens { bleed: hex(0xf03030), error: hex(0xe34850), warning: hex(0xf0a330), + axis_x: hex(0xef6058), + axis_y: hex(0x45af63), + axis_z: hex(0x378ef0), }; match b { Brightness::Dark => base, diff --git a/docs/mcp.md b/docs/mcp.md index e5582c8f..5650634f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -819,6 +819,34 @@ default) white ones knock out instead; `"discard"` writes none. {"name":"run_command","arguments":{"command":"view.overprintPreview","params":{"on":true}}} ``` +## Revolve (initial 3D effect) + +`effect.apply {effect: "threeD.revolve", params: {...}, ids?, item?}` applies a live Revolve to a path +or compound path. `effect.dialog` opens compact options with artboard rotation and light gizmos, `effect.setParams` edits it, and +`effect.expandAppearance` bakes it into shaded vector faces. Native saves keep the editable source; +SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is supported per profile. + +Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0), +`edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspective` +(0..100%, default 0), `segments` (8..128, default 64), `shade` (default true), `lightAzimuth` +(degrees, default -45), `lightElevation` (degrees, default 45), `lightIntensity` (0..100%, default 80), +`ambient` (0..100%, default 25), and `expandVisibleOnly` (boolean, default true). A vertical-line profile needs a positive offset to have a radius. +Open profiles have no end caps. Visibility of intersecting surfaces is approximate. +The dialog's Lighting tab manipulates the same `lightAzimuth` and `lightElevation` parameters; +its guide visibility and tab choice are UI state, excluded from saved effect parameters. +`expandVisibleOnly` is saved with the effect. It trims covered portions of opaque solid surfaces only +when running `effect.expandAppearance`, preserving visible interiors through openings. Set it to false +to expand all faces. Live preview and SVG/PDF baking keep the full surface. Transparent/unknown paint, +non-normal blending, later geometry effects or bounded visibility failures preserve all faces. +See [Revolve](revolve.md) for the scope and limitations. + +```json +{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.revolve","item":null,"params":{"angle":360,"offset":20,"edge":"left","rotationX":0,"rotationY":0,"rotationZ":0}}}} +{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"angle":180,"lightAzimuth":30}}}} +{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"expandVisibleOnly":true}}}} +{"name":"run_command","arguments":{"command":"effect.expandAppearance","params":{}}} +``` + ## Edit Colors and Recolor Artwork `edit.colors.invert`, `edit.colors.toCMYK`, `edit.colors.toGrayscale`, `edit.colors.toRGB`, `edit.colors.saturate`, diff --git a/docs/revolve.md b/docs/revolve.md new file mode 100644 index 00000000..9591ef73 --- /dev/null +++ b/docs/revolve.md @@ -0,0 +1,79 @@ +# Revolve + +This initial implementation follows the scope agreed in [issue #605](https://github.com/storytold/vectorcraft/issues/605#issuecomment-6070543567). + +Draw an open profile with the Pen tool, select it, then choose **Effect > 3D and Materials > Revolve**. +The profile's horizontal distance from a vertical axis is its radius. **Axis side** chooses the left or right +edge of the profile's bounds; **Offset** moves the axis outward. For a cylinder made from a vertical +line, give it a nonzero offset. A vertical line at zero offset lies on the axis and has no surface. + +**Revolve angle** sweeps 0 to 360 degrees. **Rotation X/Y/Z** rotates the viewing direction, with all three axes defaulting to 0 degrees. **Perspective** +is zero for an orthographic view. **Segments** controls radial resolution (8 to 128 for a full turn). +Lighting is a directional light with azimuth, elevation, intensity and ambient contribution. +Turn **Shading** off to keep the source paint without lighting. + +The dialog has **Rotation** and **Lighting** tabs. Rotation groups the profile and view controls. Drag a value or its label +to adjust it, use its slider, or double-click the value to type. Angles show degrees and lighting +values show percentages. **Segments** is in the **Advanced** section. Lighting controls are disabled +when **Shading** is off. A filled dot at the end of a row means the value differs from its default; +click the dot to reset that value. **Reset all** restores every Revolve parameter to its default. + +The compact settings dialog opens beside the artboard. With **Preview** enabled, the selected +object has X (red), Y (green) and Z (blue) rotation rings directly on the artboard. Drag a ring to +adjust that axis; drag inside the rings to rotate freely. Hold Shift to snap to 15-degree increments. +The rings and the rotation fields stay in sync. Escape during a drag restores that gesture and +keeps the settings open. Escape again or Cancel restores the document before the dialog opened. +The gizmo appears while editing Revolve; normal source-path selection returns when the dialog closes. + +Select the **Lighting** tab to use the light gizmo on the artboard. Drag the sun handle to move +its direction on the current hemisphere. A hollow sun marks a back light. Drag the **Direction** +ring or its label horizontally to adjust azimuth; drag the **Elevation** arc or its label vertically +to adjust elevation. The labels remain usable when a ring is viewed edge-on or the light is at a pole. +Hold Shift to snap to 15-degree increments. Light gestures update their matching fields without +rotating the object, and keep the same temporary preview, Escape, Cancel and undo behaviour. +Light intensity and ambient contribution remain available as draggable fields and sliders. + +**Shading** off hides the light gizmo and disables lighting controls. Switch to **Rotation** to return +to the object rings. **Show axis** in **Advanced** displays the source's vertical axis on the object. +Turning **Preview** off restores the original artwork and hides artboard controls until Preview is enabled. +In small windows, the controls scroll while OK and Cancel stay visible. Reset dots restore individual +settings; **Reset all** restores parameters on both tabs. + +Preview changes are temporary until OK. Cancel restores the document; OK creates one undo step. +The source anchors remain editable. Appearance lists the effect for reopening its options. +SVG/PDF export evaluates filled vector faces; native saves retain the profile and effect parameters. +**Object > Expand Appearance** replaces the live effect with ordinary editable vector faces. + +In **Advanced**, **Keep visible surfaces only when expanding** is on by default. Expansion removes +completely covered faces and trims partially covered ones using the current rotation and perspective. +Visible interior surfaces through open ends remain. Uncheck it to retain every projected face. +The checkbox has its own reset dot and is included in **Reset all**. Its saved command parameter is +`expandVisibleOnly`; it does not change the live preview or SVG/PDF export baking. + +Trimming applies to opaque solid paint. Transparent objects, gradients, patterns, non-normal blending, +later geometry effects and surfaces exceeding bounded visibility limits keep their complete geometry +to preserve their appearance. Visibility follows the same painter order as the live effect, including +its approximation for intersecting surfaces. Expansion changes are undoable with the standard command. + +## Initial limits + +- Paths and compound paths only. Type, images and groups must be converted to paths first. +- Open profiles produce uncapped surfaces. Closed profile boundaries are swept as provided. +- One Revolve per source object. Edit its options rather than applying another. +- Object-level Revolve uses the top visible fill, falling back to a stroke. Per-item Revolve uses that item's paint. +- Flat shading, one directional light, no textures, cast shadows, end caps or physical materials. +- Faces use back-to-front depth sorting. Intersecting profiles can have incorrect visibility. +- Curves use sixteen samples per segment; profiles are limited to 1,024 samples and surfaces to 32,768 faces. +- Axis, bounds and ordinary selection handles continue to refer to the editable 2D source profile. +- Extrude & Bevel, Inflate, Rotate and Materials remain unimplemented. + +## Architecture + +`vectorcraft-three-d` depends only on `vectorcraft-geom`. It creates a surface of revolution, +applies Euler view rotation, projects it, shades faces and sorts their depth. It has no UI or GPU dependency. +`vectorcraft-effects` turns those faces into document art. The renderer, export baking and +Expand Appearance use the same evaluator. The standard `effect.apply`, `effect.setParams`, +`effect.remove` and `effect.expandAppearance` commands expose it to the CLI, control channel and MCP. + +Behaviour reference: [public 3D effects documentation](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html). +This implementation uses original mathematics and source code, with no Adobe software, assets or presets. diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index e72c0c30..d1994261 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -41,6 +41,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("trace", Class::Layer(2)), ("text", Class::Layer(2)), ("effects", Class::Layer(2)), + ("three-d", Class::Layer(2)), ("render", Class::Layer(3)), ("svg", Class::Layer(3)), ("pdf", Class::Layer(3)), @@ -78,6 +79,7 @@ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[ // Non-native art in Illustrator editing data is a PDF, drawn by the PDF importer. &["pdf", "eps"], &["plugins", "effects"], + &["three-d", "effects"], ]; fn intra_layer_allowed(from: &str, to: &str) -> bool {