diff --git a/AGENTS.md b/AGENTS.md index c52a4551..3ee06312 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,8 +51,8 @@ tools/guictl.py drive the GUI from a script -- list the widgets on screen, click them, read the framebuffer back. tools/gui_mcp.py is the same surface as an MCP server; docs/notes/gui-automation.md -reference/scripts/ dataset preprocessing CLI tools (Python, standalone; - mask.py is embedded into the GUI binary) +reference/scripts/ dataset preprocessing CLI tools (Python, standalone, + run by hand; nothing in the build uses them) reference/python/ hand-run tools on NO code path: eval_lpips.py, benchmark.py, camera_utils.py (the unported orientation_method / center_method reference, diff --git a/cmake/SsApps.cmake b/cmake/SsApps.cmake index 6eebeb0f..99af0c51 100644 --- a/cmake/SsApps.cmake +++ b/cmake/SsApps.cmake @@ -29,7 +29,7 @@ function(ss_configure_app target) target_include_directories(${target} PRIVATE ${SS_SRC} - ${CMAKE_BINARY_DIR} # app_generated/{viewer_html,mask_py}.h + ${CMAKE_BINARY_DIR} # app_generated/*.h ${CUDAToolkit_INCLUDE_DIRS} ) target_link_libraries(${target} PRIVATE ${SS_APP_LIBS}) @@ -198,14 +198,6 @@ if(SS_BUILD_GUI) target_link_libraries(imgui_glfw PUBLIC glfw) set_property(TARGET imgui_glfw PROPERTY CXX_STANDARD 17) - # Embed reference/scripts/mask.py (AI masking helper, run via external - # Python) so the exe is self-contained. Same mechanism as the - # viewer.html embed. - ss_embed_file( - ${SS_ROOT}/reference/scripts/mask.py - ${CMAKE_BINARY_DIR}/app_generated/mask_py.h - MaskPy) - # ---- fonts (src/app/gui/Fonts.h, docs/i18n.md) ---- # # The Latin/Cyrillic face IS embedded: at 59 KB it costs nothing, and @@ -257,11 +249,9 @@ if(SS_BUILD_GUI) list(APPEND SS_TOOL_LIBS "-framework AppKit") endif() - # In-process segmentation (interactive preview + dataset masking) and, when - # patented modules are enabled, in-process video decoding. Both are - # optional: DatasetPrep falls back to python + reference/scripts/mask.py - # and to ffmpeg, and the GUI hides what this build cannot do rather than - # failing at run time. + # In-process segmentation and, with SS_ENABLE_PATENTED, video decoding. + # Without them the GUI masks only fixed areas of the frame and decodes with + # ffmpeg, and says so rather than failing at run time. if(SS_BUILD_SAM) list(APPEND SS_TOOL_DEFS SS_BUILD_SAM=1) if(SS_ENABLE_PATENTED) @@ -466,6 +456,7 @@ if(SS_BUILD_GUI) ${SS_SRC}/app/FrameLook.cpp ${SS_SRC}/app/gui/mask/Livewire.cpp ${SS_SRC}/app/gui/mask/PathTool.cpp + ${SS_SRC}/app/gui/mask/PenTool.cpp ${SS_SRC}/app/gui/Picture.cpp ${SS_SRC}/app/gui/mask/MaskSlideshow.cpp) ss_configure_app(mask_doc_test) diff --git a/cmake/SsEmbed.cmake b/cmake/SsEmbed.cmake index 0dac971f..16daafb0 100644 --- a/cmake/SsEmbed.cmake +++ b/cmake/SsEmbed.cmake @@ -1,5 +1,5 @@ # Embedding data files into the executables as byte arrays, so the apps are -# self-contained (no runtime lookup of viewer.html or reference/scripts/mask.py). +# self-contained (no runtime lookup of viewer.html, fonts or icons). # ss_hex_to_literal( ) # diff --git a/cmake/SsOptions.cmake b/cmake/SsOptions.cmake index f3e4ee76..70c2b2f9 100644 --- a/cmake/SsOptions.cmake +++ b/cmake/SsOptions.cmake @@ -176,13 +176,9 @@ else() endif() # --------------------------------------------------------------------------- -# GPU inference (src/nn/) and segmentation (src/sam/) -# -# The native replacement for the reference/scripts/mask.py subprocess: SAM 2 -# / SAM 3 on the same Vulkan + Slang stack as the SfM module, over a -# reusable inference layer. Same rule as SfM -- Vulkan-only, on by default -# only for the Vulkan build, opt-in for CUDA if the Vulkan SDK is present. -# See cmake/SsNn.cmake and src/nn/README.md. +# GPU inference (src/nn/) and SAM segmentation (src/sam/). Vulkan-only like +# SfM: on by default for the Vulkan build, opt-in for CUDA with the Vulkan SDK. +# Without it the GUI has no model-based masking. See cmake/SsNn.cmake. # --------------------------------------------------------------------------- if(SS_BACKEND STREQUAL "vulkan") option(SS_BUILD_SAM "Build the inference layer + SAM segmentation" ON) diff --git a/docs/build.md b/docs/build.md index 12058dac..cf2bbe04 100644 --- a/docs/build.md +++ b/docs/build.md @@ -331,9 +331,9 @@ Notarization needs a paid Apple Developer account, so it is not wired into the build. One behaviour is bundle-specific: a Finder launch inherits launchd's PATH -(`/usr/bin:/bin:/usr/sbin:/sbin`), which has no Homebrew in it, so COLMAP, -ffmpeg and python3 would be missing from an app that finds them fine when -started from a shell. `gui::add_desktop_search_paths()` +(`/usr/bin:/bin:/usr/sbin:/sbin`), which has no Homebrew in it, so COLMAP and +ffmpeg would be missing from an app that finds them fine when started from a +shell. `gui::add_desktop_search_paths()` (`src/app/AppPaths.h`) appends the package managers' directories at startup, after any PATH the process actually inherited. diff --git a/docs/notes/exr.md b/docs/notes/exr.md index 3a60b318..9b4205d9 100644 --- a/docs/notes/exr.md +++ b/docs/notes/exr.md @@ -103,10 +103,10 @@ maximum of exactly 1.0 across a whole scene-linear capture. `ImageCompare`'s "Original file" pane, which quantizes the file's own values with no transfer curve so it shows what is stored rather than a display of it. -The one path that cannot read an EXR is the **external masking fallback**, -`reference/scripts/mask.py`: Pillow has no EXR reader. It now counts the files -it could not open and says so at the end of the run rather than leaving the -capture silently unmasked. The built-in masking (`SS_BUILD_SAM`) reads them. +Masking (`SS_BUILD_SAM`) reads them too. The hand-run +`reference/scripts/mask.py` cannot -- Pillow has no EXR reader -- so it counts +the files it could not open and says so at the end of the run rather than +leaving the capture silently unmasked. ## Testing diff --git a/docs/notes/frame-stencil.md b/docs/notes/frame-stencil.md index 387698af..623eac01 100644 --- a/docs/notes/frame-stencil.md +++ b/docs/notes/frame-stencil.md @@ -1,10 +1,11 @@ # Fixed areas of the frame: the stencil, its tools and its file "Remove fixed areas of the frame" on the dataset screen is `app::FrameStencil`: -per input, a fitted fisheye border plus shapes drawn by hand. Both are geometry, -not segmentation, so they need no model and are the same on every frame of a -camera. This note covers the drawn half: what a shape is, the tools that draw -one, the SVG file a set of them is saved as, and how a saved set reaches a +per input, a fitted fisheye border plus shapes drawn by hand, optionally +different for each of the input's cameras. Both are geometry, not segmentation, +so they need no model and are the same on every frame of a camera. This note +covers the drawn half: what a shape is, the tools that draw one, per-camera +areas, the SVG file a set of them is saved as, and how a saved set reaches a dataset preset and a batch run. ## Shapes @@ -17,8 +18,15 @@ and y by height: |---|---|---| | `Rect` | two corners in `cx,cy` / `rx,ry` | Box | | `Ellipse` | centre `cx,cy`, radii `rx,ry` | Ellipse | -| `Path` | `pts`: 3+ corners, closed, even-odd | Lasso, Polygon, Path (livewire) | +| `Path` | `pts`: 3+ corners, closed, even-odd | Lasso, Path (livewire) | | `Stroke` | `pts`: 1+ points; half-width `rx,ry` per axis | Brush, Eraser | +| `Bezier` | `pts`: 2+ anchors of six floats (in-handle, point, out-handle), closed, even-odd | Pen | + +A `Bezier`'s segment i runs from anchor i's point through its out-handle and +anchor i+1's in-handle to that anchor's point, the last back to the first; a +corner has both handles on its point. The math (flattening by Wang's bound, +splitting, nearest point, the smooth-handle rule) is `core/CubicBezier.h`, +shared by the rasterizer, the pen tool and the editor. Each shape either removes what is inside it or keeps it. Shapes apply **in order**, the last one covering a pixel decides it, and the base is "keep" @@ -35,28 +43,66 @@ detected radius; Ctrl+click allows numeric entry within that range. Preview and batch processing both apply `app::shrink_border`. **Edit border ellipse** converts the current adjusted circle into the first -keep-shape, turns off automatic detection, and starts a new shape edit history. -The mask is unchanged by conversion; Select can then move its centre and resize -its two axes. This fixed ellipse is saved with the other shapes and applies to -the whole input, so use separate inputs for cameras with different borders. -Automatic detection must receive `shrink=0` before conversion; the stencil's -shrink is applied once when the ellipse is inserted. +keep-shape and turns off automatic detection; it is one undo step. The mask is +unchanged by conversion; Select can then move its centre and resize its two +axes. On an input with several cameras it first turns on **Separate areas for +each camera** (below), so the ellipse is the shown camera's alone and the other +lenses keep their own fitted borders. Automatic detection must receive +`shrink=0` before conversion; the stencil's shrink is applied once when the +ellipse is inserted. ## Tools `SegmentPanel` (Try the mask...) has the mask editor's tool row over the -picture: Select `V`, Box `B`, Ellipse `E`, Lasso `L`, Polygon `P`, Brush `C`, -Eraser `X`, Path `I`, and Add / Subtract. A plain stroke adds to what is removed; -Subtract, the eraser and Ctrl each flip that to keep, so Ctrl with the eraser -removes again, as in the mask editor. Undo and redo (Ctrl+Z, Ctrl+Shift+Z, -Ctrl+Y) cover every change to the list, including moves, flips and loads. -Under Select, clicks on the picture still prompt the model; pick a shape in the -list to move or resize it. +picture: Select `V`, Points `A`, Box `B`, Ellipse `E`, Lasso `L`, Pen `P`, +Brush `C`, Eraser `X`, Path `I`, and Add / Subtract. A plain stroke adds to +what is removed; Subtract, the eraser and Ctrl each flip that to keep, so Ctrl +with the eraser removes again, as in the mask editor. Undo and redo (Ctrl+Z, +Ctrl+Shift+Z, Ctrl+Y) cover every change to the stencil, including moves, +flips, loads, the border conversion and the per-camera switch. Under Select, +clicks on the picture still prompt the model; pick a shape in the list to move +or resize it. The tools are the 3D editor's `EditTool` producing a `ShapeStroke` in canvas pixels; `stencil_shape_from_stroke` (`src/app/gui/StencilEdit.h`) turns that -into a `MaskShape`. That file also holds hit testing, moving, resize handles and -the undo history, with no ImGui, and `stencil_edit_test` covers it. +into a `MaskShape`. That file also holds hit testing, moving, resize handles, +a pen shape's points and the undo history, with no ImGui, and +`stencil_edit_test` covers it. + +### The pen + +`P`, with the grammar Illustrator, Photoshop, Figma and Affinity share, so the +Polygon tool is gone from this row (a pen that is only clicked is a polygon): + +| input | does | +|---|---| +| click | a corner anchor | +| drag | a smooth anchor: the out-handle follows the pointer, the in-handle mirrors it | +| Shift | the new anchor, or the handle, snaps to 45-degree steps | +| Alt while dragging | the in-handle stays put; only the out-handle moves (a cusp) | +| Space while dragging | moves the anchor being placed; the handles resume from there | +| click the last anchor | takes its out-handle back, so the next segment leaves straight | +| Ctrl+drag | moves an anchor or handle already placed (the "direct selection for now") | +| click the first anchor, Enter, right click | closes the path; a drag on the first anchor also sets its handles | +| Ctrl+Z, Backspace | take the last anchor back | +| Esc | cancels the path | + +A badge beside the pointer shows a ring (close), a caret (straighten) or an +arrow (edit), where a vector editor would change the cursor. With a pen shape +selected, the pen also edits it: a click on its outline adds an anchor there +(the curve does not change), a click on an anchor deletes it, and Alt+click on +an anchor makes it a corner, or Alt+drag pulls new symmetric handles out of it. + +`PenTool` (`src/app/gui/mask/PenTool.h`) is the same class in the mask editor, +where the closed path is flattened and painted. Its points are kept in frame +pixels through `PathSpace`, as the livewire path's are. + +**Points** (`A`, direct selection) shows a pen shape's anchors as squares and +handles as circles. Drag either; a handle of a smooth anchor swings the other +to stay opposite at its own length, and Alt+drag moves it alone. Shift keeps +45-degree steps. Delete or Backspace removes the picked anchor (never below +two). A click inside another shape picks it; other kinds move and resize as +under Select. ## The file: SVG in normalized coordinates @@ -83,45 +129,100 @@ the undo history, with no ImGui, and `stencil_edit_test` covers it. axis. A viewer draws `stroke-width`, their geometric mean doubled, which is as close as one SVG width can get. - `` is the name the set is listed under. +- `data-camera` on the root, when present, says the file is one camera's + part of a per-camera set (below). + +A `Bezier` is a `<path>` of `C` commands, one per segment, straight ones +included, ending on the first anchor; the reader folds that last anchor back +into the first, so a file round-trips to the same anchors. The reader also takes hand-made SVG: `rect`, `circle`, `ellipse`, `polygon`, -`polyline`, `line` and `path` (all commands, curves and arcs flattened to 24 -segments), `style=""` and presentation attributes, inherited through `<g>`, -and any `viewBox` (pixel coordinates are fine). A path's subpaths become one -shape each, so a hole is a later keep shape rather than an even-odd subpath. -`transform`, `<use>` and `<image>` are refused by name rather than misplaced. +`polyline`, `line` and `path` (all commands), `style=""` and presentation +attributes, inherited through `<g>`, and any `viewBox` (pixel coordinates are +fine). A filled subpath with a curve (`C`, `S`, `Q`, `T`; quadratics raised to +cubics) and no arc becomes a `Bezier`, so a shape drawn in Inkscape or +Illustrator stays editable with Points; one with an arc is flattened to 24 +segments per curve. A path's subpaths become one shape each, so a hole is a +later keep shape rather than an even-odd subpath. `transform`, `<use>` and +`<image>` are refused by name rather than misplaced. + +## Per camera + +One input can write several cameras: the two lenses of a dual-fisheye `.insv` +or `.osv` (`cam0/`, `cam1/`), a folder of `.insp` photos cut in two, the views +a GoPro `.360` is unwrapped into (ten `cam0/`..`cam9/` faces by default, the +two EAC tracks with 360 unwrapping off, one image for equirectangular), or a +folder of photos in subfolders. By default the shapes are the input's and every +camera gets them, and the border is fitted per camera. + +**Separate areas for each camera**, under the shapes, gives each camera its own +`app::CameraStencil`: border on/off, shrink and shapes. Turning it on copies +the input's stencil to every camera the panel knows, so nothing changes until +you draw; the **Camera** picker then chooses which one the tools edit. Turning +it off keeps the shown camera's as the input's. `FrameStencil::cameras` is +keyed by the folder the frames land in, exactly as `group_frames_by_camera` +and DatasetPrep's `StencilRaster` key them, and a camera it does not list uses +the input's. The panel keys a packed photo's lens as `cam<k>` plus the photo's +own subfolder, which is where the split writes it. + +### Per-camera sets + +A saved set (`app::MaskSet`) is one shape list for every camera, or one list +per camera. On disk a per-camera set is one SVG per camera, all with the same +`<title>`, each naming its camera in `data-camera`; a camera with nothing drawn +has no file. `app::mask_set_of` decides which an input is: one list when it has +no separate areas or when every camera draws the same, per camera otherwise. +`app::apply_mask_set` puts a set back, each named camera its own list and every +other camera the set's shared one. The fitted border is never in a set, but a +list that starts with a keep shape (an edited border ellipse, say) is the kept +region already, and the fit would only widen it, since keep shapes add up; so +loading one turns that camera's fit off. Every other border setting stays. + +`app::load_mask_svg_set` reads a file as its set: a file naming no camera on +its own, a file naming one together with every file beside it that has its +title and names a camera. So any one file of a set, picked anywhere a file is +picked, loads the whole set. A camera name is a folder key (`cam1`, `cam0/sub` +for a packed photo), and a set only means something on an input whose cameras +are keyed the same way. ## Where a saved set goes -- **Saved**: `<config>/presets/stencil/<name>.svg`, from **Save...** in the - panel (`StencilPreset.h`). **Load...** there replaces the shapes on the input - the panel is open on. +- **Saved**: `<config>/presets/stencil/<name>.svg`, or `<name>-<camera>.svg` + per camera, from **Save...** in the panel (`StencilPreset.h`). With separate + areas on it saves the whole input's set, not the shown camera's. Saving a + name again removes every file of the old set first. **Load...** puts a + one-list set where the tools draw (the input's, or the shown camera's with + separate areas on); a per-camera set turns separate areas on and goes on + the cameras it names, and is refused, with its camera names, on an input + that has none of them. The list shows a per-camera set once, as + `Name [cam0, cam1]`. - **Every input**: the dataset screen's **Drawn areas** picker, under the - fixed-areas checkbox, draws a saved set on every input, including inputs - added afterwards. Editing the shapes in the panel drops the name, since it no - longer describes what is drawn. + fixed-areas checkbox, draws a saved set on every input, per camera for a + per-camera set, including inputs added afterwards. Editing the shapes in the + panel drops the name, since it no longer describes what is drawn. - **Presets and batch**: the name is `mask_frame_shapes` in a dataset preset. A batch row whose preset has the fixed-areas option on gets the border fit and that set on every input; a name that no longer resolves fails the row. -- **With the dataset**: a run writes each input's drawn shapes to - `<dataset>/frame_stencil/<input>.svg` (the folder holds only the latest - run's), so they survive a session nobody saved from. **Load...** lists them - under "In this dataset", and **Other file...** loads any SVG, such as - another dataset's. -- **CLI**: `spirula sam mask --shape <file>.svg` reads the same file. +- **With the dataset**: a run writes each input's set to + `<dataset>/frame_stencil/`: `<input>.svg`, or `<input>-<camera>.svg` per + camera when its cameras differ, titled `<dataset> - <input file>`. Two + inputs of one name get `<input>-2` and a `(2)` title, so their camera files + never load back as one set. The folder holds only the latest run's, so they + survive a session nobody saved from. **Load...** lists them under "In this + dataset", and **Other file...** loads any SVG, with its set. +- **CLI**: `spirula sam mask --shape <file>.svg` reads the same files; one + camera's file brings its set, each camera's list for its own folder under + `--frames`. -## Adding a shape kind (the pen tool) +## Adding a shape kind -A pen tool with Bezier handles is the next kind. The places it touches: +`Bezier` is the worked example. The places a kind touches: -1. `MaskShape::Kind` and its fields (e.g. anchors and control points in `pts`). -2. `rasterize_frame_mask`: flatten into the kind's own plane, as `Path` and - `Stroke` do, so the ordering rule is untouched. +1. `MaskShape::Kind` and its fields. +2. `rasterize_frame_mask`: fill the kind's own plane, as `Path`, `Stroke` and + `Bezier` do, so the ordering rule is untouched. 3. `parse_mask_shapes` / `format_mask_shapes`, the CLI spelling. -4. `write_mask_svg`: a `<path>` with `C` commands. `parse_svg_path` already - reads them; keep the control points by mapping `C` to the new kind instead - of flattening, when the element carries its `data-op`. -5. `StencilEdit`: `stencil_contains`, `stencil_move`, and handles for the - anchors and control points. +4. `write_mask_svg` and `read_mask_svg`. +5. `StencilEdit`: `stencil_contains`, `stencil_move`, and its handles. 6. `SegmentPanel`: the tool button and key, the outline, and the list label. 7. `frame_mask_test` (raster and SVG round trip) and `stencil_edit_test`. diff --git a/docs/notes/gpu-selection-plan.md b/docs/notes/gpu-selection-plan.md index a6e22de5..15e3bff4 100644 --- a/docs/notes/gpu-selection-plan.md +++ b/docs/notes/gpu-selection-plan.md @@ -15,13 +15,13 @@ GUI acceptance remain unverified because this checkout has no model weights or bounded capture fixtures and no GUI launch was performed. The external compatibility tail is intentionally not implemented. COLMAP is -not installed, the external Python masking dependencies are unavailable, and -the installed ffmpeg path has no application-level GPU routing in this plan. -Do not describe external COLMAP/Python work as honoring the native selector. +not installed, and the installed ffmpeg path has no application-level GPU +routing in this plan. Do not describe external COLMAP work as honoring the +native selector. ## Goal and scope -Make the selected GPU control **built-in SfM, masking, and geometry**, in the GUI and native CLI. Selection means the physical device that executes the work, not merely the name shown by the training picker. Complete and verify this native milestone before implementing external COLMAP/Python routing. +Make the selected GPU control **built-in SfM, masking, and geometry**, in the GUI and native CLI. Selection means the physical device that executes the work, not merely the name shown by the training picker. Complete and verify this native milestone before implementing external COLMAP routing. | Native milestone | Required coverage | |---|---| @@ -29,7 +29,7 @@ Make the selected GPU control **built-in SfM, masking, and geometry**, in the GU | Masking | SAM 2 and SAM 3; GUI preview and dataset masking; native segment, track, mask, extract, and GPU-backed video paths | | Geometry | MoGe-2 and Metric3D v2; GUI preview, dataset-run child process, and CLI | | Supporting integration | Existing Vulkan training choice, early preview/capability probing, native decoder sharing of NN, and runtime teardown/recreation | -| Separate compatibility tail | External COLMAP and the existing external Python masking path; their flags, device namespaces, visibility rules, and fallback policy | +| Separate compatibility tail | External COLMAP; its flags, device namespaces, visibility rules, and fallback policy | Not included: multi-GPU execution, one GPU assignment per stage, hot switching, merging the three Vulkan runtimes, model/kernel changes, a new Python dependency, a new recovery system, or CUDA validation. Keep patented decoding disabled by default. External ffmpeg hardware-acceleration routing is not added by this plan; preserve the existing ffmpeg fallback. @@ -50,7 +50,7 @@ The implementation sources, rather than older architecture plans, establish this | Native job propagation | `src/app/gui/SfmRunner.cpp`, `recon_args`, omits `--device`; the same arguments feed `SfmInProcess.cpp` and the self-child path. `GeometryRunner.cpp` also omits it. `SegmentPanel`, `DatasetPrep`, and `GeometryPanel` do not receive the training choice. | | Premature first use | With `SS_HAVE_VIDEO`, `DatasetPrep::backends()` caches a call to `VideoPipeline::availability()`, which creates the NN context. Video preview/open can also use NN before model loading. Merely presenting preprocessing capabilities can therefore consume the first-use decision. | | Teardown | `nn::shutdown()` destroys stream, pipelines, pools, allocations, and context. Dataset preparation invokes it at job end; preview panels can retain the context after unloading weights. A later context generation currently selects afresh. The training backend has a different, process-lifetime context. | -| External processes | `Subprocess.cpp` uses an inherited environment (`CreateProcess` with a null environment block, or `execvp`). `ColmapRunner` and the Python masking launch in `DatasetPrep` provide no explicit GPU routing. COLMAP's existing `ba_use_gpu` boolean is not a device selection. | +| External processes | `Subprocess.cpp` uses an inherited environment (`CreateProcess` with a null environment block, or `execvp`). `ColmapRunner` provides no explicit GPU routing. COLMAP's existing `ba_use_gpu` boolean is not a device selection. | `backend::DeviceInfo`, `nn::DeviceInfo`, and the private SfM context expose no device UUID today. In a CUDA build the existing training picker enumerates CUDA devices, not Vulkan devices; its integer must never be forwarded as a native Vulkan index. @@ -133,7 +133,6 @@ The following lanes share Phase A's resolved-device contract. They may run concu - Remove selection-specific `SS_VK_DEVICE` mutation in `sam_extract`; configure the native request before any decoder or model first use. Leave unrelated validation/profiling behavior unchanged. - Have Session/model reuse compare effective physical identity, not two textual spellings of the same selector. Default model allocation and trackers inherit the configured device. - Preserve both SAM families, existing prompt/tracking semantics, and model unloading. Native video and masking share the selected NN device where decoding is built in. The normal ffmpeg decode fallback remains available on a device without video support. -- Keep external Python masking unchanged in this lane; the UI must not claim that it honors the native selector yet. **Acceptance:** actual SAM 2 visual and SAM 3 text/visual masks run on the chosen UUID in preview and batch paths; tracking spans multiple frames. Segment, track, extract, and GPU-backed video agree on numeric/name/UUID semantics. Preview followed by a dataset job, NN shutdown, and another preview retains the choice. A decode capability probe cannot select a different device first. @@ -160,21 +159,20 @@ The following lanes share Phase A's resolved-device contract. They may run concu 6. Report the actual resolved UUID/name at workload startup through existing log facilities. Verify identity at every context creation, including later SfM worker contexts; avoid per-kernel logging or a new telemetry system. A copied argv value or one process-global name banner is insufficient evidence. 7. Run the native acceptance matrix below. After it passes, update the existing subsystem/app/backend documentation that describes independent selection and environment precedence; remove throwaway smoke artifacts. Do not create an unrelated documentation or recovery subsystem. -**Native completion gate:** all three built-in workflows obey one selection in their real GUI/CLI surfaces, lifecycle/error cases pass, and native child routing is explicit. This gate can ship without external COLMAP or Python device routing. Mark external routing as independent in interface/help text rather than implying an app-wide guarantee it does not yet provide. +**Native completion gate:** all three built-in workflows obey one selection in their real GUI/CLI surfaces, lifecycle/error cases pass, and native child routing is explicit. This gate can ship without external COLMAP device routing. Mark external routing as independent in interface/help text rather than implying an app-wide guarantee it does not yet provide. ### Phase D — External compatibility tail This is a separately reviewable follow-on, not a hidden prerequisite of Phases A–C. -**Files:** `src/app/gui/ColmapRunner.{h,cpp}`, external Python launch in `DatasetPrep.cpp`, `src/app/gui/Subprocess.{h,cpp}`, and `reference/scripts/mask.py` only if its existing CLI needs an explicit device option. +**Files:** `src/app/gui/ColmapRunner.{h,cpp}` and `src/app/gui/Subprocess.{h,cpp}`. -1. Inventory the actual launched COLMAP/Python GPU operations and supported versions. Check the installed command's help for GPU index/use flags; do not assume the same option names across COLMAP releases. Extraction, matching, and GPU BA are separate operations. Preserve existing fisheye/CPU algorithm decisions. +1. Inventory the actual launched COLMAP GPU operations and supported versions. Check the installed command's help for GPU index/use flags; do not assume the same option names across COLMAP releases. Extraction, matching, and GPU BA are separate operations. Preserve existing fisheye/CPU algorithm decisions. 2. Define a compatibility mapping from native physical identity to the external runtime's identity/visible ordinal. Never pass a Vulkan integer straight to CUDA. Match a queried identity where supported; otherwise require an explicitly labeled external-runtime device override. Names alone are not sufficient on a machine with identical GPUs. 3. Account for the caller's existing visibility restrictions and ordinal remapping. Scope any environment override to the specific child process; leave the parent and unrelated children unchanged. Extend the existing `run_process` environment support only as much as these launch sites require, on Windows and POSIX. 4. For COLMAP, emit only supported device flags at each GPU operation and retain its CPU controls. A boolean such as `ba_use_gpu` cannot establish which GPU ran BA. Verify against the supported installed versions, not solely the latest web documentation. -5. For external Python masking, apply the selected external device before the framework initializes it. Reuse the existing script/interpreter path; do not add Python to the native build, import reference tooling into native execution, or bundle model/framework dependencies. -6. If the selected native device has no supported external-runtime counterpart, report that explicitly and offer the existing native path or an explicit external/CPU choice where that tool supports one. Do not silently choose the first NVIDIA GPU, select CPU, or override user visibility restrictions while claiming the original GPU was honored. -7. Verify external routing separately on the supported COLMAP/Python installations: actual selected device, existing visibility filters, invalid/missing mappings, unavailable framework/backend, and child-environment isolation. No new CUDA backend validation project is part of this tail. +5. If the selected native device has no supported external-runtime counterpart, report that explicitly and offer the existing native path or an explicit external/CPU choice where that tool supports one. Do not silently choose the first NVIDIA GPU, select CPU, or override user visibility restrictions while claiming the original GPU was honored. +6. Verify external routing separately on the supported COLMAP installations: actual selected device, existing visibility filters, invalid/missing mappings, unavailable framework/backend, and child-environment isolation. No new CUDA backend validation project is part of this tail. Reference: [COLMAP CLI documentation](https://colmap.github.io/cli.html). The installed executable's help remains authoritative for its supported flags. diff --git a/docs/notes/mask-editor.md b/docs/notes/mask-editor.md index dee772ba..039feb36 100644 --- a/docs/notes/mask-editor.md +++ b/docs/notes/mask-editor.md @@ -22,23 +22,35 @@ On macOS, Ctrl below means Command, as elsewhere in the app. | tool | key | what it does | |---|---|---| -| Box, Ellipse, Lasso, Polygon, Brush | `B` `E` `L` `P` `C` | the 3D editor's shapes, painted into the mask | +| Box, Ellipse, Lasso, Brush | `B` `E` `L` `C` | the 3D editor's shapes, painted into the mask | +| Pen | `P` | Bezier curves, as in a vector editor; clicks alone make a polygon | | Eraser | `X` | the brush with the modes swapped: a plain drag keeps | | Path | `I` | pen with livewire: each anchor snaps to the edge | | SAM | `G` | click an object, or type what to drop | -Shapes, brush and path paint with the 3D editor's selection grammar: +Shapes, brush, pen and path paint with the 3D editor's selection grammar: a plain or Shift drag **drops**, Ctrl **keeps**, Shift+Ctrl **clears** the correction back to what the run wrote. The modifiers are read when the shape -completes. **Add / Subtract**, beside the frame slider, swaps drop and keep -for every tool, SAM clicks included: under Subtract a plain drag keeps and Ctrl -drops. The eraser swaps them too, so under Subtract it drops. Right click or Enter closes a polygon; Ctrl+Z takes back its last -point. Esc cancels a shape in progress. +completes, or, for the pen and the path, when the first anchor is placed. +**Add / Subtract**, beside the frame slider, swaps drop and keep for every +tool, SAM clicks included: under Subtract a plain drag keeps and Ctrl drops. +The eraser swaps them too, so under Subtract it drops. Esc cancels a shape in +progress. **Brush size** is in mask pixels, 1 to 4096, and shared by the brush and the eraser. The slider shows while either is active; `[` and `]` step it, and Alt+wheel over the picture changes it continuously. +**Pen.** A click drops a corner and a drag a smooth anchor whose handles +follow the pointer; Shift snaps to 45-degree steps, Alt while dragging moves +the out-handle alone, Space while dragging moves the anchor, a click on the +last anchor straightens the next segment, and Ctrl+drag moves any anchor or +handle already placed. Hold Ctrl (keep) or Shift+Ctrl (clear) when you place +the first anchor. Click the first anchor, press Enter or right click to close +and paint the inside; Ctrl+Z or Backspace removes an anchor; Esc cancels. The +full table is in [frame-stencil.md](frame-stencil.md#the-pen); the path keeps +frame pixels, so zooming mid-path is fine. + **Path.** Click along an edge to drop anchors; the segment from the last anchor follows the lowest-cost edge path to the cursor (Mortensen and Barrett's intelligent scissors). Click the first anchor, press Enter or right @@ -139,7 +151,8 @@ MaskDoc one frame in memory: base, two layers, composite, undo history MaskWindow the view: pane <-> mask mapping and the window texture's pixels MaskSession the frames, the worker, the scan, the open frame, SAM, slideshow MaskPanel.cpp the window: canvas, tools, status, keys; the only GL -PathTool / Livewire the pen and its edge search +PathTool / Livewire the livewire path and its edge search +PenTool the Bezier pen, shared with the stencil panel MaskAdd / MaskSam SAM results to stencils / the checkpoint and its job thread MaskSlideshow the slideshow's decoder ring and clock ``` diff --git a/docs/notes/rename-and-i18n-plan.md b/docs/notes/rename-and-i18n-plan.md index 86b46363..17cd45c5 100644 --- a/docs/notes/rename-and-i18n-plan.md +++ b/docs/notes/rename-and-i18n-plan.md @@ -781,7 +781,7 @@ correct regional face if the user switches away from the embedded region. Keep the fetch path compiled in for every value except `none`. Embedding reuses `ssplat_embed_file()` from `cmake/SsplatEmbed.cmake` — the -same mechanism as `viewer.html` and `mask.py`. Fonts are OFL-1.1, which is +same mechanism as `viewer.html`. Fonts are OFL-1.1, which is GPLv3-compatible for bundling; ship the licence text alongside, and do not rename the font files (OFL reserved font name clause). diff --git a/docs/notes/sfm-port-plan.md b/docs/notes/sfm-port-plan.md index 028978d9..8f39c8ed 100644 --- a/docs/notes/sfm-port-plan.md +++ b/docs/notes/sfm-port-plan.md @@ -34,7 +34,7 @@ It replaces `src/app/gui/ColmapRunner.cpp`'s COLMAP half: `colmap feature_extractor / *_matcher / mapper / model_merger / bundle_adjuster`, the vocabulary-tree download, and the COLMAP version check. It does **not** replace ColmapRunner's other half — ffmpeg frame extraction, sharpest-frame selection, -multi-track `.insv` splitting, and AI masking via `reference/scripts/mask.py` — which is +multi-track `.insv` splitting, and AI masking — which is shared, not COLMAP-specific, and gets factored out for both paths (phase 5). Output is unchanged in kind: `<workspace>/sparse/0/{cameras,images,points3D}.bin` @@ -269,8 +269,8 @@ The user installs nothing either way: the child is our own binary, found via `AppPaths::exe_path`, not via PATH. Everything else landed as written: `DatasetPrep` (item 1, and it grew built-in -video decoding and masking with the ffmpeg/Python subprocesses kept as -fallbacks), `Screen::NewDataset` and the engine selector (item 3), the beginner +video decoding with the ffmpeg subprocess kept as a fallback, and in-process +masking), `Screen::NewDataset` and the engine selector (item 3), the beginner panel with its auto-detection (item 4), and the settings persistence (item 6). Item 5 — the "All SfM options" editor over the phase-2 descriptor table — was diff --git a/src/app/AppPaths.h b/src/app/AppPaths.h index 3b179ca2..39558f8a 100644 --- a/src/app/AppPaths.h +++ b/src/app/AppPaths.h @@ -26,8 +26,8 @@ std::string exe_path(); std::string exe_dir(); // macOS only. A Finder launch inherits launchd's PATH -- /usr/bin:/bin: -// /usr/sbin:/sbin -- so colmap, ffmpeg and python3 are invisible to the bundle -// though a shell finds them. Appends, so an inherited PATH still wins. +// /usr/sbin:/sbin -- so colmap and ffmpeg are invisible to the bundle though a +// shell finds them. Appends, so an inherited PATH still wins. void add_desktop_search_paths(); } // namespace app diff --git a/src/app/FrameMask.cpp b/src/app/FrameMask.cpp index d59a7b84..6e576705 100644 --- a/src/app/FrameMask.cpp +++ b/src/app/FrameMask.cpp @@ -3,6 +3,7 @@ #include "app/FrameMask.h" #include "app/FrameLook.h" +#include "core/CubicBezier.h" #include "core/ExrImage.h" #include "core/PolygonFill.h" @@ -575,7 +576,7 @@ MaskShape shrink_border(MaskShape shape, float shrink) { return shape; } -bool edit_detected_border(FrameStencil& stencil, const BorderDetect& border) { +bool edit_detected_border(CameraStencil& stencil, const BorderDetect& border) { if (!stencil.detect_border || !border.found) return false; stencil.mask.shapes.insert(stencil.mask.shapes.begin(), shrink_border(border.shape, stencil.shrink)); @@ -583,6 +584,47 @@ bool edit_detected_border(FrameStencil& stencil, const BorderDetect& border) { return true; } +bool MaskSet::empty() const { + for (const auto& [camera, list] : cameras) + if (!list.empty()) return false; + return shapes.empty(); +} + +MaskSet mask_set_of(const FrameStencil& stencil) { + MaskSet out; + if (!stencil.per_camera()) { + out.shapes = stencil.mask.shapes; + return out; + } + // The input's own list is only a fallback here: every camera the panel + // knew was given an entry when separate areas were turned on. + const std::string first = format_mask_shapes(stencil.cameras.begin()->second.mask.shapes); + bool same = true; + for (const auto& [camera, cs] : stencil.cameras) + same = same && format_mask_shapes(cs.mask.shapes) == first; + if (same) { + out.shapes = stencil.cameras.begin()->second.mask.shapes; + return out; + } + for (const auto& [camera, cs] : stencil.cameras) out.cameras[camera] = cs.mask.shapes; + return out; +} + +void apply_mask_set(FrameStencil& stencil, const MaskSet& set) { + auto put = [](CameraStencil& cs, const std::vector<MaskShape>& list) { + cs.mask.shapes = list; + if (!list.empty() && !list.front().remove) cs.detect_border = false; + }; + put(stencil, set.shapes); + for (auto& [camera, cs] : stencil.cameras) put(cs, set.shapes); + for (const auto& [camera, list] : set.cameras) { + auto it = stencil.cameras.find(camera); + if (it == stencil.cameras.end()) + it = stencil.cameras.emplace(camera, static_cast<const CameraStencil&>(stencil)).first; + put(it->second, list); + } +} + // --------------------------------------------------------------------------- // Shapes // --------------------------------------------------------------------------- @@ -621,6 +663,17 @@ bool parse_mask_shapes(const std::string& spec, std::vector<MaskShape>& out, out.push_back(s); continue; } + if (kind == "bezier") { + s.kind = MaskShape::Kind::Bezier; + if (!parse_floats(nums, s.pts) || + s.pts.size() < 2 * bezier::kAnchorFloats || + s.pts.size() % bezier::kAnchorFloats) { + error = piece; + return false; + } + out.push_back(s); + continue; + } if (kind == "stroke") { s.kind = MaskShape::Kind::Stroke; if (!parse_floats(nums, s.pts) || s.pts.size() < 4 || s.pts.size() % 2 || @@ -661,7 +714,12 @@ std::string format_mask_shapes(const std::vector<MaskShape>& shapes) { char buf[96]; for (const MaskShape& s : shapes) { std::string piece = s.remove ? "-" : ""; - if (s.kind == MaskShape::Kind::Path || s.kind == MaskShape::Kind::Stroke) { + if (s.kind == MaskShape::Kind::Bezier) { + // A handle is dragged by a fraction of a pixel; four places lose that at 8K. + piece += "bezier "; + for (size_t i = 0; i < s.pts.size(); i++) + append_printf(piece, "%s%.5f", i ? "," : "", s.pts[i]); + } else if (s.kind == MaskShape::Kind::Path || s.kind == MaskShape::Kind::Stroke) { const bool stroke = s.kind == MaskShape::Kind::Stroke; piece += stroke ? "stroke " : "path "; if (stroke) append_printf(piece, "%.5f,%.5f", s.rx, s.ry); @@ -728,8 +786,9 @@ bool rasterize_frame_mask(const FrameMask& m, int width, int height, } const bool base = m.shapes.empty() || m.shapes.front().remove; - // A path or stroke is filled once into its own plane; the pixel loop then - // reads it like any other inside test, so the ordering rule is untouched. + // A path, curve or stroke is filled once into its own plane; the pixel + // loop then reads it like any other inside test, so the ordering rule is + // untouched. std::vector<std::vector<uint8_t>> paths(m.shapes.size()); std::vector<float> px; for (size_t k = 0; k < m.shapes.size(); k++) { @@ -739,12 +798,19 @@ bool rasterize_frame_mask(const FrameMask& m, int width, int height, fill_stroke(s, width, height, paths[k].data()); continue; } - if (s.kind != MaskShape::Kind::Path) continue; - paths[k].assign((size_t)width * height, 0); - px.resize(s.pts.size()); - for (size_t i = 0; i + 1 < s.pts.size(); i += 2) { - px[i] = s.pts[i] * (float)width; - px[i + 1] = s.pts[i + 1] * (float)height; + if (s.kind == MaskShape::Kind::Bezier) { + paths[k].assign((size_t)width * height, 0); + bezier::flatten_closed(s.pts.data(), s.pts.size() / bezier::kAnchorFloats, + (float)width, (float)height, 0.05f, px); + } else if (s.kind == MaskShape::Kind::Path) { + paths[k].assign((size_t)width * height, 0); + px.resize(s.pts.size()); + for (size_t i = 0; i + 1 < s.pts.size(); i += 2) { + px[i] = s.pts[i] * (float)width; + px[i + 1] = s.pts[i + 1] * (float)height; + } + } else { + continue; } polyfill::fill_even_odd(px.data(), px.size() / 2, width, height, paths[k].data(), 1); } @@ -759,7 +825,7 @@ bool rasterize_frame_mask(const FrameMask& m, int width, int height, for (size_t k = 0; k < m.shapes.size(); k++) { const MaskShape& s = m.shapes[k]; bool inside; - if (s.kind == MaskShape::Kind::Path || s.kind == MaskShape::Kind::Stroke) { + if (!paths[k].empty()) { inside = paths[k][(size_t)y * width + x] != 0; } else if (s.kind == MaskShape::Kind::Ellipse) { if (s.rx <= 0.0f || s.ry <= 0.0f) continue; @@ -1027,11 +1093,12 @@ int64_t apply_frame_stencil(const FrameStencilRun& run, if (sinks.cancel && sinks.cancel->load()) return written; if (sinks.camera) sinks.camera(rel, (int64_t)files.size()); - FrameMask fm = run.stencil.mask; + const CameraStencil& stencil = run.stencil.for_camera(rel); + FrameMask fm = stencil.mask; BorderDetect border; - if (run.stencil.detect_border) { + if (stencil.detect_border) { BorderDetectOptions o = run.detect; - o.shrink = run.stencil.shrink; + o.shrink = stencil.shrink; border = detect_fisheye_border(files, o); // First, so the shapes drawn on top are applied to it in order. if (border.found) fm.shapes.insert(fm.shapes.begin(), border.shape); diff --git a/src/app/FrameMask.h b/src/app/FrameMask.h index ca2a53cb..5c15a528 100644 --- a/src/app/FrameMask.h +++ b/src/app/FrameMask.h @@ -21,14 +21,14 @@ namespace app { struct MaskShape { - enum class Kind { Ellipse, Rect, Path, Stroke }; + enum class Kind { Ellipse, Rect, Path, Stroke, Bezier }; Kind kind = Kind::Ellipse; bool remove = false; // false = keep what is inside float cx = 0.5f, cy = 0.5f; // rect: the two corners go in cx,cy / rx,ry // Stroke: its half-width per axis, so a brush stays round on any aspect. float rx = 0.5f, ry = 0.5f; // x,y pairs. Path: 3+ corners, closed, even-odd. Stroke: 1+ points of a - // round-capped polyline, what a brush drags. + // round-capped polyline. Bezier: 2+ anchors (core/CubicBezier.h), closed. std::vector<float> pts; }; @@ -46,8 +46,8 @@ struct FrameMask { }; // "ellipse 0.5,0.5,0.49,0.49; -rect 0.2,0.9,0.8,1; path 0.1,0.1,0.9,0.1,0.5,0.9": -// ';'-separated, '-' removes. rect: two corners; ellipse: centre, radii; path: -// 3+ corners; stroke: rx, ry, then 1+ points. The file form is app/FrameMaskSvg.h. +// ';'-separated, '-' removes; rect, ellipse, path, stroke (rx, ry, points) and +// bezier (six numbers an anchor) as MaskShape stores them. File: FrameMaskSvg.h. bool parse_mask_shapes(const std::string& spec, std::vector<MaskShape>& out, std::string& error); std::string format_mask_shapes(const std::vector<MaskShape>& shapes); @@ -126,19 +126,50 @@ private: // Applying a stencil to a folder of frames // --------------------------------------------------------------------------- -// One input's decision: the shapes drawn on it, and whether the lens circle -// should also be fitted. Both, because the two answer different questions -- -// a dual-fisheye file writes two cameras whose circles differ by about 1% of +// One camera's decision: the shapes drawn on it, and whether its lens circle is +// fitted. Both, because a dual-fisheye file's two circles differ by about 1% of // the radius, and whatever drew the shapes only ever saw one of them. -struct FrameStencil { +struct CameraStencil { FrameMask mask; bool detect_border = false; float shrink = 0.01f; // overrides BorderDetectOptions::shrink bool empty() const { return mask.empty() && !detect_border; } }; +// One input's: shared by all of its cameras, except those listed in `cameras` +// by the folder their frames land in ("cam1", a 360 view, a photo subfolder -- +// group_frames_by_camera's keys), which get their own instead. +struct FrameStencil : CameraStencil { + std::map<std::string, CameraStencil> cameras; + bool per_camera() const { return !cameras.empty(); } + const CameraStencil& for_camera(const std::string& camera) const { + const auto it = cameras.find(camera); + return it == cameras.end() ? *this : it->second; + } + bool empty() const { + for (const auto& [name, c] : cameras) + if (!c.empty()) return false; + return CameraStencil::empty(); + } +}; + // The detection must have shrink=0; the stencil supplies the adjustment. -bool edit_detected_border(FrameStencil& stencil, const BorderDetect& border); +bool edit_detected_border(CameraStencil& stencil, const BorderDetect& border); + +// A saved set of drawn areas: `shapes` for every camera, except each camera +// `cameras` names, which gets its own. A per-camera set is one SVG per camera. +struct MaskSet { + std::vector<MaskShape> shapes; + std::map<std::string, std::vector<MaskShape>> cameras; + bool per_camera() const { return !cameras.empty(); } + bool empty() const; +}; +// What an input draws, as one list when every camera draws the same. +MaskSet mask_set_of(const FrameStencil& stencil); +// Replaces every drawn shape of `stencil` with `set`'s. A list starting with a +// keep shape (an edited border ellipse) is the kept region already, and the +// fitted circle would only widen it, so its camera's fit goes off. +void apply_mask_set(FrameStencil& stencil, const MaskSet& set); // Image files under `dir`, grouped by the folder holding them -- a folder is a // camera, which is what a multi-track extraction writes (cam0/, cam1/). Keys diff --git a/src/app/FrameMaskSvg.cpp b/src/app/FrameMaskSvg.cpp index a299de82..2f280229 100644 --- a/src/app/FrameMaskSvg.cpp +++ b/src/app/FrameMaskSvg.cpp @@ -2,11 +2,14 @@ #include "app/FrameMaskSvg.h" +#include "core/CubicBezier.h" + #include <algorithm> #include <cctype> #include <cmath> #include <cstdio> #include <cstdlib> +#include <filesystem> #include <fstream> #include <map> #include <sstream> @@ -359,6 +362,20 @@ void arc(std::vector<float>& pts, float x1, float y1, float rx, float ry, float } } +// A path's last anchor sitting on its first is the same anchor, reached +// again: it keeps the first's out-handle and brings its own in-handle. +void close_anchors(std::vector<float>& a) { + constexpr size_t k = bezier::kAnchorFloats; + const size_t n = a.size() / k; + if (n < 2) return; + const float* last = &a[(n - 1) * k]; + const float tol = 1e-6f * std::max({1.0f, std::fabs(a[2]), std::fabs(a[3])}); + if (std::fabs(last[2] - a[2]) > tol || std::fabs(last[3] - a[3]) > tol) return; + a[0] = last[0]; + a[1] = last[1]; + a.resize((n - 1) * k); +} + } // namespace bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::string& error) { @@ -369,10 +386,15 @@ bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::str float cx2 = 0, cy2 = 0; char prev = 0, cmd = 0; SvgSubpath* cur = nullptr; + // Per subpath in `out`: whether it had a curve, and an arc. + std::vector<char> curved, arced; auto begin = [&](float nx, float ny) { out.push_back({}); + curved.push_back(0); + arced.push_back(0); cur = &out.back(); cur->pts = {nx, ny}; + cur->anchors = {nx, ny, nx, ny, nx, ny}; x = sx = nx; y = sy = ny; }; @@ -380,6 +402,15 @@ bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::str if (!cur || cur->closed) begin(x, y); return cur; }; + auto line_anchor = [&](float px, float py) { + cur->anchors.insert(cur->anchors.end(), {px, py, px, py, px, py}); + }; + auto curve_anchor = [&](float x1, float y1, float x2, float y2, float ex, float ey) { + cur->anchors[cur->anchors.size() - 2] = x1; + cur->anchors[cur->anchors.size() - 1] = y1; + cur->anchors.insert(cur->anchors.end(), {x2, y2, ex, ey, ex, ey}); + curved.back() = 1; + }; while (!p.at_end()) { if (p.at_command()) cmd = p.command(); else if (!cmd) { @@ -407,16 +438,19 @@ bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::str x = ox + v[0]; y = oy + v[1]; need()->pts.insert(cur->pts.end(), {x, y}); + line_anchor(x, y); break; case 'H': if (!read(1)) { error = d; return false; } x = (rel ? x : 0.0f) + v[0]; need()->pts.insert(cur->pts.end(), {x, y}); + line_anchor(x, y); break; case 'V': if (!read(1)) { error = d; return false; } y = (rel ? y : 0.0f) + v[0]; need()->pts.insert(cur->pts.end(), {x, y}); + line_anchor(x, y); break; case 'C': case 'S': { @@ -436,6 +470,7 @@ bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::str cy2 = oy + v[1]; const float ex = ox + v[2], ey = oy + v[3]; cubic(need()->pts, x, y, x1, y1, cx2, cy2, ex, ey); + curve_anchor(x1, y1, cx2, cy2, ex, ey); x = ex; y = ey; break; @@ -455,6 +490,10 @@ bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::str } const float ex = ox + v[0], ey = oy + v[1]; quad(need()->pts, x, y, cx2, cy2, ex, ey); + // Degree elevation: the cubic with these handles IS the quadratic. + curve_anchor(x + 2.0f / 3.0f * (cx2 - x), y + 2.0f / 3.0f * (cy2 - y), + ex + 2.0f / 3.0f * (cx2 - ex), ey + 2.0f / 3.0f * (cy2 - ey), + ex, ey); x = ex; y = ey; break; @@ -468,6 +507,8 @@ bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::str } const float ex = ox + v[3], ey = oy + v[4]; arc(need()->pts, x, y, v[0], v[1], v[2], large, sweep, ex, ey); + line_anchor(ex, ey); + arced.back() = 1; x = ex; y = ey; break; @@ -483,6 +524,11 @@ bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::str } prev = up; } + for (size_t k = 0; k < out.size(); k++) { + if (curved[k] && !arced[k]) close_anchors(out[k].anchors); + if (!curved[k] || arced[k] || out[k].anchors.size() < 2 * bezier::kAnchorFloats) + out[k].anchors.clear(); + } return true; } @@ -490,12 +536,15 @@ bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::str // Writing // --------------------------------------------------------------------------- -std::string write_mask_svg(const std::vector<MaskShape>& shapes, const std::string& title) { +std::string write_mask_svg(const std::vector<MaskShape>& shapes, const std::string& title, + const std::string& camera) { std::string o; o += "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"; // width/height only size a viewer's window; the geometry is the viewBox. o += "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 1 1\" width=\"1024\" " - "height=\"1024\" preserveAspectRatio=\"none\">\n"; + "height=\"1024\" preserveAspectRatio=\"none\""; + if (!camera.empty()) o += " data-camera=\"" + escape_xml(camera) + "\""; + o += ">\n"; if (!title.empty()) o += " <title>" + escape_xml(title) + "\n"; o += " Spirula Studio frame stencil. Coordinates are normalized to the image, " "(0,0) top left to (1,1) bottom right. Shapes paint in order: black removes, " @@ -528,6 +577,22 @@ std::string write_mask_svg(const std::vector& shapes, const std::stri "\" fill-rule=\"evenodd\"/>\n"; break; } + case MaskShape::Kind::Bezier: { + // Every segment a C, straight ones too, so it reads back as a + // Bezier; the last ends on the first anchor, which reading merges. + const size_t n = s.pts.size() / bezier::kAnchorFloats; + if (n == 0) break; + std::string d = "M" + num(s.pts[2]) + " " + num(s.pts[3]); + for (size_t i = 0; i < n; i++) { + float c[8]; + bezier::segment(s.pts.data(), n, i, c); + d += " C" + num(c[2]) + " " + num(c[3]) + " " + num(c[4]) + " " + + num(c[5]) + " " + num(c[6]) + " " + num(c[7]); + } + o += head + "path data-op=\"" + op + "\" d=\"" + d + " Z\" fill=\"" + ink + + "\" fill-rule=\"evenodd\"/>\n"; + break; + } case MaskShape::Kind::Stroke: { std::string pts; for (size_t i = 0; i + 1 < s.pts.size(); i += 2) @@ -556,9 +621,10 @@ std::string write_mask_svg(const std::vector& shapes, const std::stri // --------------------------------------------------------------------------- bool read_mask_svg(const std::string& text, std::vector& out, std::string& title, - std::string& error) { + std::string& error, std::string* camera) { out.clear(); title.clear(); + if (camera) camera->clear(); float vx = 0, vy = 0, vw = 1, vh = 1; bool seen_svg = false; std::vector