Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 4bc0837867 Fix #221: Effect > Pathfinder works on several selected objects
Choosing an Effect > Pathfinder item (Add, Intersect, Exclude, Subtract...)
with several loose objects selected now groups them and applies the live
effect to the group, in one undo step, so the result shows at once and stays
editable. A selection that already holds a group, or a single object, works
as before, and agents get `grouped: true` back when it happens.

Root cause: Pathfinder effects combine the contents of a group or layer.
Applied to loose objects, each one got an effect with nothing to combine, so
nothing visibly changed, while the Pathfinder panel's commands (found by the
command search) act on the selection directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:21:06 +07:00
BFlat 2ba0e1f6e5 Merge pull request #226 from storytold/fix/issue-177
Fix #177: DXF Fit and Aligned text span their two points
2026-10-07 02:20:43 +07:00
bflatasticandClaude Opus 5.5 82be706b94 Fix #177: DXF Fit and Aligned text span their two points
DXF text justified Fit now keeps its height and stretches or squeezes
horizontally to run exactly from its first point to its second; Aligned
text scales as a whole, height included, to do the same, its width factor
kept. Attributes justified the same way follow suit. Without a second point,
or with both points the same, the text keeps its size as before.

Root cause: the importer took the angle of the two points and anchored the
text at the first, but kept the text's height and width factor, ignoring the
distance between the points. The text now records that span and, when it
becomes a text object, is measured in the font it is set in, then given the
horizontal scale (Fit) or size (Aligned) that makes it end at the second
point, kept between 1% and 10,000% of its natural length. The import's
bounds estimate uses the same scale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:14:39 +07:00
BFlat 5af667182a Merge pull request #223 from storytold/fix/issue-206
Fix #206: Live Paint treats every path as an edge, open lines included
2026-10-07 02:14:17 +07:00
bflatasticandClaude Opus 5.5 700b6786c4 Fix #206: Live Paint treats every path as an edge, open lines included
The Live Paint Bucket now fills areas enclosed by any paths. Crossing
lines enclose faces it can fill, a line across a shape splits that shape's
face in two, and an area enclosed by paths becomes a face even when nothing
fills it (it starts unfilled). Edges are the paths cut wherever another path
meets them, keeping their stroke, so Shift-click paints each piece. Closed
shapes give the same faces as before.

Root cause: Live Paint built its faces from the Pathfinder regions, which
divide the filled areas of closed shapes. An open path counted only as the
area it would fill if closed, which for a straight line is nothing, so lines
divided nothing and the bucket found no face to fill. A new planar map in
vectorcraft-pathops (live_paint) sweeps every path as an edge: an open path
goes in traced there and back, with crossing counts that never cancel; faces
are walked on the resulting graph (holes included), and their fills and the
edges' sources are found geometrically. The sweep runs in an exactly sheared
frame, because it mishandles a horizontal line traced there and back.
Make, Merge, the bucket and Live Paint Selection all use it;
interior_point moves to vectorcraft-pathops with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:08:39 +07:00
Brandon Thomas 856e36c694 Merge pull request #220 from storytold/pr/craft-fonts
Fonts: use craft-fonts as an optional build input; move the Japanese fonts out
2026-10-06 14:37:41 -04:00
Brandon ThomasandClaude Opus 5.5 721d1118f3 Fonts: docs use an absolute CRAFT_FONTS_DIR; note the web size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:19:24 -04:00
Brandon ThomasandClaude Opus 5.5 3491b99321 Fonts: the Japanese-menu glyph test skips without craft-fonts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:09:46 -04:00
Brandon ThomasandClaude Opus 5.5 546a65f77a Fonts: use craft-fonts as an optional build input; move the Japanese fonts out
Shippori Mincho (assets/fonts, from #145) moves to storytold/craft-fonts. crates/text/build.rs
embeds the fonts of a craft-fonts checkout when CRAFT_FONTS_DIR is set (web: BIZ UDPGothic
Regular only) as vectorcraft_text::CRAFT_FONTS; unset, it is empty and Japanese falls back to the
installed fonts. Document text gets the Japanese faces after the bundled fonts (Mincho first); the
UI adds them at the end of every egui family (BIZ UDPGothic first), before ui_fonts' system-font
discovery. Release and FreeBSD CI builds check craft-fonts out at a pinned commit; packages carry
the fonts' OFL texts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:42:21 -04:00
Brandon Thomas 77708ad8de Merge pull request #215 from storytold/pr/windows-arm64
Windows on ARM64: release builds, PE header check, install test on ARM64 hardware
2026-10-06 10:13:23 -04:00
Brandon ThomasandClaude Opus 5.5 129c9ca61e Windows ARM64 workflow: pin third-party actions by commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:51:04 -04:00
Brandon ThomasandClaude Opus 5.5 ceb6defcf3 Windows on ARM64: release builds, PE header check, install test on ARM64 hardware
Release builds add an arm64 MSI and portable zip, cross-compiled on the x64
runner (signing and WiX as for x64). package.ps1 checks each binary's PE
header before packaging: Machine must match -Arch, the app must be a GUI
program and the CLI a console program. It skips the CLI smoke run when an
ARM64 build can't run on the host. A new Windows ARM64 workflow packages the
ARM64 MSI, installs it on a Windows 11 ARM64 runner, checks both installed
exes are ARM64, runs the CLI, and uninstalls. Ported from printcraft#66.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:38:29 -04:00
Brandon Thomas 7a39cb7b89 Merge pull request #212 from storytold/pr/release-0.3.1
Release: VectorCraft v0.3.1
2026-10-06 08:03:26 -04:00
Brandon ThomasandClaude Opus 5.5 15ab25426e Release: VectorCraft v0.3.1
Fixes the Windows installer: the Start Menu shortcut and Add/Remove Programs icon were blank. Release builds no longer open a console window behind the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:03:13 -04:00
Brandon Thomas 899a82d31d Merge pull request #211 from storytold/pr/windows-packaging
Windows packaging: Start Menu/Add-Remove icon, no console window, correct description
2026-10-06 07:53:56 -04:00
Brandon ThomasandClaude Opus 5.5 7bca89dd15 Windows packaging: Start Menu/Add-Remove icon, no console window, correct description
The MSI Icon id had no .ico/.exe extension, which Windows Installer needs for
advertised shortcuts and ARPPRODUCTICON, so both showed a blank icon. Release
builds of the desktop app are now GUI-subsystem (no console window; the CLI stays
a console program), the shortcut/summary description names what VectorCraft does, and
packaging-lint rejects an Icon id without an extension. Found in printcraft#57.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:50:24 -04:00
Brandon Thomas 7f5344a60c Merge pull request #145 from midasdf/feat/japanese-interface-language
Add Japanese menus and initial vertical type tools
2026-10-06 07:43:47 -04:00
Brandon ThomasandClaude Opus 5.5 691a54e7fd Roadmap and README: Japanese interface and initial vertical type
The appended Interface localization section moves into the roadmap's structure: a
Shipped so far entry, the Type advanced row (45%, CJK composition left), the gap list,
the inventories (16 stubbed menu items; Touch Type the only missing tool) and totals
recomputed from the parity table (75.3%, 211–332 h to feature parity); README Status
follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:43:25 -04:00
Brandon ThomasandClaude Opus 5.5 1733fa5fdb i18n: keep only VectorCraft's own menu labels, and test that
47 of the 83 entries were labels from other apps ("New blank PDF", "Comments panel",
"Read mode"…) that never appear here. A test now walks the menu tree and fails on any
entry that isn't one of VectorCraft's labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:43:25 -04:00
Brandon Thomas 7715dcd285 Merge remote-tracking branch 'origin/main' into pr145-v2 2026-10-06 07:43:14 -04:00
Brandon Thomas 025315f4bf Merge pull request #210 from storytold/fix/clippy-byte-str
clippy: byte string in the envelope test (main's clippy step is red on Rust 1.99)
2026-10-06 07:09:43 -04:00
midasdf 44b2b984ed i18n: add Japanese menus and initial vertical type tools 2026-10-06 16:55:47 +09:00
55 changed files with 2137 additions and 164 deletions

No files matched your search

+9
View File
@@ -27,6 +27,13 @@ jobs:
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
# The optional craft-fonts build input (Japanese fonts), so the tests that need it run here
# rather than skip. Pinned; bump deliberately (craftrules standards/fonts.md).
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
@@ -44,5 +51,7 @@ jobs:
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
# The checkout above is synced into the VM with the workspace.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
+4
View File
@@ -47,6 +47,10 @@ jobs:
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/vectorcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/vectorcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.vectorcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
+41
View File
@@ -70,6 +70,13 @@ jobs:
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
@@ -90,6 +97,8 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
with:
@@ -112,11 +121,21 @@ jobs:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -141,6 +160,8 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
@@ -167,6 +188,13 @@ jobs:
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
@@ -184,6 +212,9 @@ jobs:
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
@@ -212,6 +243,13 @@ jobs:
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
@@ -227,6 +265,9 @@ jobs:
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Build and zip
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
+95
View File
@@ -0,0 +1,95 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: vectorcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: vectorcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'VectorCraft'
foreach ($exe in 'vectorcraft.exe', 'vectorcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'vectorcraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "vectorcraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok vectorcraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'vectorcraft.exe')) { throw 'uninstall left vectorcraft.exe behind' }
Write-Output 'ok uninstalled'
+2
View File
@@ -5,3 +5,5 @@ plan/
/corpus
/log
.DS_Store
# A craft-fonts checkout for CRAFT_FONTS_DIR (CI and release workflows check it out here)
/craft-fonts
+1
View File
@@ -22,6 +22,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Creative Commons with redistribution allowed.
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
+4
View File
@@ -6,6 +6,10 @@ Every non-code asset in this repository (icons, images, fonts, example art, colo
Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets and the vector tool cursors (`crates/ui-egui/src/cursors.rs`).
**Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). The small Latin UI/document fonts below predate that rule and stay.
**Optional build input: craft-fonts.** Builds made with `CRAFT_FONTS_DIR=<craft-fonts checkout>` (all official releases) embed the Japanese fonts listed in its [`ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md): BIZ UDPGothic Regular and Bold, Shippori Mincho Regular and BIZ UDMincho Regular (web builds: BIZ UDPGothic Regular only), all under the SIL Open Font License 1.1. They are not files in this repository; release packages carry each one's `OFL-<family>.txt`. Builds without `CRAFT_FONTS_DIR` embed none of them.
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `docs/brand/artcraft-logo-white.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
+1
View File
@@ -22,6 +22,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Creative Commons with redistribution allowed.
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
Generated
+25 -25
View File
@@ -4211,7 +4211,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"arboard",
"clipboard-win",
@@ -4231,7 +4231,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4243,7 +4243,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4259,7 +4259,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"serde_json",
"vectorcraft-color",
@@ -4275,7 +4275,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4287,7 +4287,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4301,7 +4301,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@@ -4315,7 +4315,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"flate2",
"image",
@@ -4347,7 +4347,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"flate2",
"image",
@@ -4364,7 +4364,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"flate2",
"proptest",
@@ -4378,7 +4378,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4390,7 +4390,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"image",
"log",
@@ -4409,7 +4409,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4423,7 +4423,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4435,7 +4435,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"aes",
"flate2",
@@ -4462,7 +4462,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"serde",
"serde_json",
@@ -4476,7 +4476,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"flate2",
"gif",
@@ -4501,7 +4501,7 @@ dependencies = [
[[package]]
name = "vectorcraft-svg"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"flate2",
"image",
@@ -4525,7 +4525,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4542,7 +4542,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"harfrust 0.13.3",
"kurbo 0.13.1",
@@ -4555,7 +4555,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4570,7 +4570,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4583,7 +4583,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"egui",
"egui_extras",
@@ -4611,7 +4611,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"eframe",
"egui",
@@ -5685,7 +5685,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.3.0"
version = "0.3.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+5
View File
@@ -29,3 +29,8 @@ based in part on the work of the Independent JPEG Group.
WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is
under the Apache License 2.0 with the LLVM exception.
Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official releases) embed the
fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ
UDPGothic, Shippori Mincho and BIZ UDMincho; web builds BIZ UDPGothic Regular only) under the SIL
Open Font License 1.1. Their license texts ship with the release packages as OFL-<family>.txt.
+8 -2
View File
@@ -154,6 +154,10 @@ cd apps/vectorcraft-web && trunk build --release # web build → dist
cargo xtask ci # fmt, clippy, tests, layering, wasm, vendor names
```
Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional
build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path).
Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input).
### Use it from Claude Code and other agents
Register the MCP server with Claude Code:
@@ -178,7 +182,7 @@ self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-0
**What's missing:**
- 3D and Materials;
- the Photoshop-style raster effects (Effect Gallery);
- vertical and CJK type;
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
- Variables and scripting;
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
- packaging for Windows and Linux.
@@ -241,7 +245,9 @@ VectorCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-AP
Copyright (c) 2026 ArtCraft Team and the VectorCraft contributors. Required notices are in [NOTICE](NOTICE).
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
with its author, source and license in [ASSETS.md](ASSETS.md).
with its author, source and license in [ASSETS.md](ASSETS.md). Release builds also embed the
Japanese fonts of [craft-fonts](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md)
(SIL Open Font License 1.1).
The app icon (an engraved dragon on VectorCraft red, `#e8573f`) is the owner's original artwork; its
palette and files are in [`assets/app-icon/`](assets/app-icon/README.md).
+20 -18
View File
@@ -14,8 +14,8 @@ _Last updated: 2026-10-06 (after M4.14–M4.98, M8.1–M8.20 and M14.4–M14.7;
| Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** |
| Feature surface vs full Illustrator (weighted, see below) | **~75%** on the rubric · **69–75%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) |
| Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~40–55%** |
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~225–345 h** one agent · **~60–100 h** with 4–6 agents |
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~330–515 h** one agent · **~85–150 h** with 4–6 agents |
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~210–330 h** one agent · **~55–95 h** with 4–6 agents |
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~320–500 h** one agent · **~80–145 h** with 4–6 agents |
## Honest assessment (2026-10-05)
@@ -33,9 +33,9 @@ since 2026-10-01.
| Dimension | Estimate | Evidence and what's missing |
|---|---|---|
| **Breadth:** menus, tools and panels exist | ~90% | 18 menu items still stubbed; every tool implemented except Touch Type and vertical type ×3; 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 (~35%), brushes and symbols (in progress), raster effects (~20%) |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, vertical/CJK type, Variables (data merge), scripting |
| **Breadth:** menus, tools and panels exist | ~90% | 16 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%), brushes and symbols (in progress), raster effects (~20%) |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type has initial support), Variables (data merge), 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, Place and Links, Package, Print, clipboard flavours. Native `.ai` private data is out of scope by design; DWG has no open spec |
@@ -58,9 +58,10 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
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.
4. **Advanced type:** vertical type and its tools, CJK composition, Optical Margin Alignment, the
composer/hyphenation options, tab leaders, a spell-check dictionary (open licence), Touch Type and Snap to Glyph.
41–55 h across type core and advanced.
4. **Advanced type:** CJK composition for vertical type (kinsoku, ruby, tate-chu-yoko, vertical font metrics,
mixed-script orientation), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
initial support. 35–48 h across type core and advanced.
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
generated in code (never Adobe's); dynamic symbols; Start Global Edit. 14–22 h.
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
@@ -111,8 +112,9 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
- 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), 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), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline.
- **Japanese interface and vertical type:** VectorCraft ▸ Language switches the menus between English and Japanese (untranslated labels stay English) and persists; the bundled OFL Shippori Mincho gives Japanese glyphs without system fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs with `vert`/`vrt2` alternates); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening.
@@ -153,7 +155,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~330–515** |
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** |
## Parity estimate
@@ -171,7 +173,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|---|---:|---:|---|---:|
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pen/pencil modifier nuances, Touch Type | 10–15 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 85% | Live Paint gap options, Shape Builder edge cases | 3–6 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder edge cases | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code) | 6–10 |
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
@@ -179,7 +181,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 |
| Type advanced | 4 | 35% | vertical type/CJK, tab leaders, spell check (open dictionary), Touch Type, Retype | 26–35 |
| Type advanced | 4 | 45% | CJK composition for vertical type (kinsoku, ruby, tate-chu-yoko, vertical metrics, mixed-script orientation), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 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 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
@@ -191,17 +193,17 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows (Links, Asset Export, CSS Properties, Attributes are done) | 15–25 |
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
| **Feature parity** | **103** | **~75%** | | **~225–345** |
| **Feature parity** | **103** | **~75%** | | **~210–330** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
| **Full parity** | | **~56%** | | **~330–515** |
| **Full parity** | | **~56%** | | **~320–500** |
With 4–6 agents working on disjoint crates (as the layering allows) the wall-clock time divides by roughly 3.5–4
(integration, review and shared files such as `menus.rs` serialize some work): **~60–100 h** to feature parity,
**~85–150 h** to full parity.
(integration, review and shared files such as `menus.rs` serialize some work): **~55–95 h** to feature parity,
**~80–145 h** to full parity.
_Inventories (2026-10-05):_ 18 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
implemented except four (Touch Type, vertical type ×3); 51 panel modules; Illustrator-style live
_Inventories (2026-10-05):_ 16 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
implemented except Touch Type; 51 panel modules; Illustrator-style live
effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out,
+1
View File
@@ -5,6 +5,7 @@
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `vectorcraft_ui_egui::control` for the methods.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
mod clipboard;
mod control_server;
+18 -8
View File
@@ -18,6 +18,7 @@ enum Handle {
}
pub struct NativeMenu {
language: vectorcraft_ui_egui::i18n::Language,
_menu: Menu,
items: HashMap<String, (String, Value, Handle, String)>,
last_refresh: f64,
@@ -47,7 +48,7 @@ impl NativeMenu {
let mut items = HashMap::new();
let mut counter = 0usize;
for (title, entries) in menus::menu_tree() {
let sub = Submenu::new(title, true);
let sub = Submenu::new(app.ui.language.tr(title), true);
build(app, &sub, &entries, &mut items, &mut counter);
let _ = menu.append(&sub);
}
@@ -58,7 +59,15 @@ impl NativeMenu {
.filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some())
.map(|(_, _, _, c)| c.clone())
.collect();
Self { _menu: menu, items, last_refresh: 0.0, generation, slots: menus::listed_slots(app), plugins: menus::plugin_revision() }
Self {
_menu: menu,
items,
last_refresh: 0.0,
generation,
slots: menus::listed_slots(app),
plugins: menus::plugin_revision(),
language: app.ui.language,
}
}
/// Dispatch clicked items and refresh state.
@@ -79,6 +88,7 @@ impl NativeMenu {
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation
|| menus::listed_slots(app) != self.slots
|| menus::plugin_revision() != self.plugins
|| app.ui.language != self.language
{
*self = NativeMenu::install(app);
return;
@@ -111,7 +121,7 @@ impl NativeMenu {
"view.cornerWidget" | "view.textThreads" | "view.gradientAnnotator" | "effect.last" | "type.hiddenCharacters"
)
{
i.set_text(menus::dynamic_label(app, cmd, ""));
i.set_text(app.ui.language.tr(&menus::dynamic_label(app, cmd, "")));
}
}
Handle::Check(c) => {
@@ -136,13 +146,13 @@ fn build(
let _ = parent.append(&PredefinedMenuItem::separator());
}
Item::Header(h) => {
let _ = parent.append(&MenuItem::new(*h, false, None));
let _ = parent.append(&MenuItem::new(app.ui.language.tr(h), false, None));
}
Item::Todo(label, sc) => {
let _ = parent.append(&MenuItem::new(*label, false, accel(sc)));
let _ = parent.append(&MenuItem::new(app.ui.language.tr(label), false, accel(sc)));
}
Item::Sub(label, children) => {
let sub = Submenu::new(*label, true);
let sub = Submenu::new(app.ui.language.tr(label), true);
build(app, &sub, children, items, counter);
let _ = parent.append(&sub);
}
@@ -153,11 +163,11 @@ fn build(
let id = format!("dc{counter}");
let sc = if params.is_null() { menus::shortcut_of(cmd).and_then(accel) } else { None };
let handle = if menus::checked(app, cmd, params).is_some() {
let c = CheckMenuItem::with_id(id.clone(), *label, true, false, sc);
let c = CheckMenuItem::with_id(id.clone(), app.ui.language.tr(label), true, false, sc);
let _ = parent.append(&c);
Handle::Check(c)
} else {
let i = MenuItem::with_id(id.clone(), *label, true, sc);
let i = MenuItem::with_id(id.clone(), app.ui.language.tr(label), true, sc);
let _ = parent.append(&i);
Handle::Plain(i)
};
+25 -4
View File
@@ -13,7 +13,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Dash, Document, LAY
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect};
use super::ImportOptions;
use super::entity::{Alpha, Col, Converter, Geom, InsertItem, Item, Lw, Props, TextItem};
use super::entity::{Alpha, Col, Converter, Geom, InsertItem, Item, Lw, Props, Span, TextItem};
use super::reader::{Drawing, LayoutDef};
use crate::{CAP_HEIGHT, MAX_NEST};
@@ -244,14 +244,18 @@ impl<'d, 'a> Build<'d, 'a> {
Geom::Text(t) => {
let lines = t.text.lines().count().max(1) as f64;
let longest = t.text.lines().map(|l| l.chars().count()).max().unwrap_or(0) as f64;
let w = longest * t.height * 0.9 * t.width;
let natural = longest * t.height * 0.9 * t.width;
// Fit and aligned text run their span; aligned text's height scales with it.
let k = t.span.and_then(|sp| sp.scale(natural)).unwrap_or(1.0);
let height = if matches!(t.span, Some(Span::Aligned(_))) { t.height * k } else { t.height };
let w = natural * k;
let x0 = match t.justify {
vectorcraft_doc::Justify::Center => -w / 2.0,
vectorcraft_doc::Justify::Right => -w,
_ => 0.0,
};
let below = (lines - 1.0) * t.leading.unwrap_or(0.0) + t.height * 0.3;
Some(t.xf.transform_rect_bbox(Rect::new(x0, -below, x0 + w, t.height)))
let below = (lines - 1.0) * t.leading.unwrap_or(0.0) + height * 0.3;
Some(t.xf.transform_rect_bbox(Rect::new(x0, -below, x0 + w, height)))
}
Geom::Insert(ins) => {
let b = self.block_bounds(&ins.block, depth);
@@ -502,6 +506,9 @@ impl<'d, 'a> Build<'d, 'a> {
let mut obj = TextObject::point(Point::ZERO, &t.text, style);
obj.xf = m * Affine::scale(1.0 / s);
obj.para.justify = t.justify;
if let Some(span) = t.span {
fill_span(&mut obj, span, s);
}
Some(Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj))))
}
@@ -581,6 +588,20 @@ impl<'d, 'a> Build<'d, 'a> {
}
}
/// Fit text stretched or squeezed across, aligned text scaled as a whole, to run its span
/// (`s`: points per drawing unit), measured in the font it is set in.
fn fill_span(obj: &mut TextObject, span: Span, s: f64) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj);
let natural = layout.lines.iter().map(|l| l.x1 - l.x0).fold(0.0, f64::max);
let Some(k) = span.scale(natural / s) else { return };
for r in &mut obj.runs {
match span {
Span::Fit(_) => r.style.h_scale *= k,
Span::Aligned(_) => r.style.size *= k,
}
}
}
fn rgb_color([r, g, b]: [u8; 3]) -> Color {
Color::rgb8(r, g, b)
}
+39 -1
View File
@@ -19,6 +19,9 @@ const LINE_SPACING: f64 = 5.0 / 3.0;
const MAX_CELLS: i64 = 10_000;
/// Art farther than this from the origin (drawing units) is damaged data, left out.
const MAX_COORD: f64 = 1e12;
/// How far fit and aligned text may be squeezed or stretched to span their two points: at width
/// factor 1, the 1 to 10,000% horizontal scale the Character panel allows.
const SPAN_SCALE: (f64, f64) = (0.01, 100.0);
/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black.
pub(crate) fn aci_paper(i: u64) -> [u8; 3] {
@@ -121,6 +124,27 @@ pub(crate) struct TextItem {
pub family: Option<String>,
/// Baseline-to-baseline distance (drawing units), for several lines.
pub leading: Option<f64>,
/// The distance aligned and fit text run along their baseline.
pub span: Option<Span>,
}
/// How text fills the distance between its two points (drawing units).
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Span {
/// Stretched or squeezed across, its height kept.
Fit(f64),
/// Scaled as a whole, height included.
Aligned(f64),
}
impl Span {
/// The scale that makes text `natural` long run the span, within [`SPAN_SCALE`] (None when
/// either length is degenerate).
pub fn scale(self, natural: f64) -> Option<f64> {
let (Self::Fit(d) | Self::Aligned(d)) = self;
let k = d / natural;
(k.is_finite() && k > 0.0).then(|| k.clamp(SPAN_SCALE.0, SPAN_SCALE.1))
}
}
/// A block reference: one transform per copy (an array insert makes several), each from the
@@ -608,10 +632,13 @@ fn text(g: &[Pair<'_>], attrib: bool) -> Option<TextItem> {
let (h, v) = (g.int_or(72, 0), g.int_or(if attrib { 74 } else { 73 }, 0));
let (p1, p2) = (g.point(10), g.point_opt(11));
let mut angle = g.num_or(50, 0.0).to_radians();
let mut span = None;
// Aligned and fit text run from the first point to the second.
let anchor = match (h, p2) {
(3 | 5, Some(p)) => {
angle = (p - p1).atan2();
let d = p1.distance(p);
span = (d > 1e-12).then_some(if h == 3 { Span::Aligned(d) } else { Span::Fit(d) });
p1
}
(0, _) if v == 0 => p1,
@@ -636,6 +663,7 @@ fn text(g: &[Pair<'_>], attrib: bool) -> Option<TextItem> {
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
family: None,
leading: None,
span,
})
}
@@ -662,5 +690,15 @@ fn mtext(g: &[Pair<'_>]) -> Option<TextItem> {
let direction = g.point_opt(11).map(|p| p.to_vec2()).filter(|d| d.hypot() > 1e-12);
let angle = direction.map_or_else(|| g.num_or(50, 0.0), |d| d.atan2());
let xf = Affine::translate(g.point(10).to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, first));
Some(TextItem { text: s, height, xf, width: 1.0, justify, style: g.name(7).unwrap_or("STANDARD").to_uppercase(), family, leading: Some(leading) })
Some(TextItem {
text: s,
height,
xf,
width: 1.0,
justify,
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
family,
leading: Some(leading),
span: None,
})
}
+161
View File
@@ -397,6 +397,167 @@ fn text_and_multiline_text() {
assert!((leading - 3.5 * 5.0 / 3.0).abs() < 1e-9, "{leading}");
}
/// Where the first line's baseline starts and ends, in document coordinates.
fn baseline_ends(t: &TextObject) -> (Point, Point) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let l = &layout.lines[0];
(t.xf * Point::new(l.x0, l.baseline), t.xf * Point::new(l.x1, l.baseline))
}
#[test]
fn fit_and_aligned_text_span_their_two_points() {
// Millimetres: red lines at x = 10 and 90, fit and aligned text between them, and the same
// text left-justified for its natural size.
let e = [
(0, "LINE"),
(62, "1"),
(10, "10"),
(20, "0"),
(11, "10"),
(21, "60"),
(0, "LINE"),
(62, "1"),
(10, "90"),
(20, "0"),
(11, "90"),
(21, "60"),
(0, "TEXT"),
(10, "10"),
(20, "40"),
(40, "5"),
(1, "FIT BETWEEN"),
(72, "5"),
(11, "90"),
(21, "40"),
(0, "TEXT"),
(10, "10"),
(20, "10"),
(40, "5"),
(41, "0.8"),
(1, "ALIGNED"),
(72, "3"),
(11, "90"),
(21, "10"),
(0, "TEXT"),
(10, "10"),
(20, "25"),
(40, "5"),
(1, "FIT BETWEEN"),
(0, "TEXT"),
(10, "10"),
(20, "50"),
(40, "5"),
(41, "0.8"),
(1, "ALIGNED"),
];
let o = ImportOptions { center: false, ..ImportOptions::default() };
let doc = open(&drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], &e), &o).document;
let [left, right, fit, aligned, fit_natural, aligned_natural] = &leaves(&doc)[..] else { panic!() };
let (x10, x90) = (bounds(left).center().x, bounds(right).center().x);
for n in [fit, aligned] {
let (a, b) = baseline_ends(text_of(n));
assert!((a.x - x10).abs() < 1e-3 && (b.x - x90).abs() < 1e-3, "{a:?} to {b:?}, not {x10} to {x90}");
assert!((a.y - b.y).abs() < 1e-9, "level");
}
// Fit keeps its height and stretches across.
let (st, natural) = (text_of(fit).first_style(), text_of(fit_natural).first_style());
assert_eq!(st.size, natural.size);
assert!(st.h_scale > 100.0, "{}", st.h_scale);
// Aligned grows as a whole, its width factor kept.
let (a, b) = baseline_ends(text_of(aligned_natural));
let k = (x90 - x10) / (b.x - a.x);
let (st, natural) = (text_of(aligned).first_style(), text_of(aligned_natural).first_style());
assert!((st.size - natural.size * k).abs() < 1e-6 && k > 2.0, "{} vs {} × {k}", st.size, natural.size);
assert!((st.h_scale - 80.0).abs() < 1e-9 && (natural.h_scale - 80.0).abs() < 1e-9);
}
#[test]
fn fit_and_aligned_text_without_a_second_point_keep_their_size() {
for h in ["3", "5"] {
let e = [
(0, "TEXT"),
(10, "10"),
(20, "10"),
(40, "7"),
(1, "Gap"),
(72, h),
(0, "TEXT"),
(10, "10"),
(20, "30"),
(40, "7"),
(1, "Gap"),
(72, h),
(11, "10"),
(21, "30"),
];
let doc = open(&entities(&e), &points()).document;
let top = height(&doc);
for (n, y) in leaves(&doc).iter().zip([10.0, 30.0]) {
let t = text_of(n);
let st = t.first_style();
assert!((st.size - 10.0).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale);
assert!((t.xf * Point::ZERO).distance(Point::new(10.0, top - y)) < 1e-9);
}
}
}
#[test]
fn fit_and_aligned_text_scale_within_limits() {
// A span a billion units long and one a millionth of a unit long.
let e = [
(0, "TEXT"),
(10, "0"),
(20, "0"),
(40, "7"),
(1, "X"),
(72, "5"),
(11, "1e9"),
(21, "0"),
(0, "TEXT"),
(10, "0"),
(20, "10"),
(40, "7"),
(1, "X"),
(72, "3"),
(11, "1e-6"),
(21, "10"),
];
let doc = open(&entities(&e), &points()).document;
let [fit, aligned] = &leaves(&doc)[..] else { panic!() };
let st = text_of(fit).first_style();
assert!((st.size - 10.0).abs() < 1e-9 && (st.h_scale - 10_000.0).abs() < 1e-6, "{} at {}%", st.size, st.h_scale);
let st = text_of(aligned).first_style();
assert!((st.size - 0.1).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale);
}
#[test]
fn fit_attributes_span_their_two_points() {
let inserts = [
(0, "INSERT"),
(66, "1"),
(2, "Door"),
(10, "0"),
(20, "0"),
(0, "ATTRIB"),
(10, "10"),
(20, "20"),
(40, "7"),
(1, "D-01"),
(2, "TAG"),
(70, "0"),
(72, "5"),
(74, "0"),
(11, "90"),
(21, "20"),
(0, "SEQEND"),
];
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
let tag = leaves(&doc).into_iter().find(|n| matches!(n.kind, NodeKind::Text(_))).unwrap();
let (a, b) = baseline_ends(text_of(&tag));
assert!((a.x - 10.0).abs() < 1e-3 && (b.x - 90.0).abs() < 1e-3, "{a:?} to {b:?}");
assert_eq!(text_of(&tag).first_style().size, 10.0);
}
/// A block "Door" (base point 5, 5) holding `block_art`, and `inserts`.
fn with_door(block_art: &[(i32, &'static str)], inserts: &[(i32, &'static str)]) -> Vec<u8> {
let mut blocks = vec![(0, "BLOCK"), (8, "0"), (2, "Door"), (70, "0"), (10, "5"), (20, "5"), (3, "Door")];
+4
View File
@@ -414,6 +414,9 @@ pub enum TextKind {
/// A text object. `runs` split into paragraphs at `\n`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextObject {
/// Top-to-bottom, right-to-left writing. Defaults to horizontal for old documents.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub vertical: bool,
pub kind: TextKind,
/// Maps text space (origin = first baseline start for point type) to the document.
pub xf: Affine,
@@ -437,6 +440,7 @@ pub struct TextObject {
impl TextObject {
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
Self {
vertical: false,
kind: TextKind::Point,
xf: Affine::translate(origin.to_vec2()),
runs: vec![TextRun { text: text.into(), style }],
+8 -51
View File
@@ -16,7 +16,7 @@ use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{FillRule, PathData, Point, Rect, Shape as _};
use vectorcraft_geom::{FillRule, Point, Rect, Shape as _};
use vectorcraft_pathops as po;
use vectorcraft_tools::builder::{
self as b, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, edge_near, face_at, is_live_paint, sample_polyline, shapes_for, sorted_roots,
@@ -205,52 +205,6 @@ fn stroke_only(paint: Paint, width: f64) -> Appearance {
Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, width))], ..Default::default() }
}
/// A point strictly inside a filled path (for re-finding faces after a rebuild).
pub(crate) fn interior_point(path: &PathData) -> Option<Point> {
let bb = path.bounds()?;
let bp = path.to_bezpath();
let mut edges: Vec<(Point, Point)> = vec![];
let (mut first, mut last) = (Point::ZERO, Point::ZERO);
kurbo::flatten(bp.iter(), (bb.width().max(bb.height()) * 1e-3).max(1e-4), |el| match el {
kurbo::PathEl::MoveTo(p) => {
if last != first {
edges.push((last, first));
}
first = p;
last = p;
}
kurbo::PathEl::LineTo(p) => {
edges.push((last, p));
last = p;
}
kurbo::PathEl::ClosePath => {
if last != first {
edges.push((last, first));
}
last = first;
}
_ => {}
});
if last != first {
edges.push((last, first));
}
let mut best: Option<(f64, Point)> = None;
for f in [0.5, 0.25, 0.75, 0.125, 0.375, 0.625, 0.875, 0.0625, 0.9375] {
let y = bb.y0 + bb.height() * f;
let mut xs: Vec<f64> =
edges.iter().filter(|(a, c)| (a.y <= y) != (c.y <= y)).map(|(a, c)| a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)).collect();
xs.sort_by(f64::total_cmp);
for w in xs.windows(2) {
let m = Point::new((w[0] + w[1]) / 2.0, y);
let width = w[1] - w[0];
if width > best.map_or(0.0, |b| b.0) && bp.winding(m) != 0 {
best = Some((width, m));
}
}
}
best.map(|b| b.1)
}
// ---------- Shape Builder ----------
fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Paint>> {
@@ -395,7 +349,11 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
src_group.name = Some(SOURCES_NAME.into());
src_group.visible = false;
children.push(Arc::new(src_group));
let regs = po::regions(&shapes);
// Every path is an edge, open ones included; faces are the areas they enclose.
let (regs, edges) = po::live_paint(&shapes);
if regs.is_empty() && edges.is_empty() {
return Err(EngineError::Other("Live Paint: these paths are too degenerate to paint".into()));
}
let nfaces = regs.len();
for r in regs {
let id = d.alloc_id();
@@ -404,10 +362,9 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
n.name = Some(FACE_NAME.into());
children.push(Arc::new(n));
}
let edges = po::pathfinder(po::PathfinderOp::Outline, &shapes);
let nedges = edges.len();
for e in edges {
let (_, stroke, w) = &styles[e.key as usize];
let Some((_, stroke, w)) = styles.get(e.key as usize) else { continue };
let id = d.alloc_id();
let mut n = Node::path(id, e.path, stroke_only(stroke.clone(), *w));
n.name = Some(EDGE_NAME.into());
@@ -451,7 +408,7 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
let keep_faces: Vec<(Point, Paint)> = b::faces(&old)
.iter()
.filter(|f| !f.appearance.fill_paint().is_none())
.filter_map(|f| Some((interior_point(f.path_data()?)?, f.appearance.fill_paint())))
.filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint())))
.collect();
let keep_edges: Vec<(Point, Appearance)> = b::edges(&old)
.iter()
+2 -1
View File
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Text",
[],
None,
"{x, y, text, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}",
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}",
has_doc,
text_create
),
@@ -401,6 +401,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
let y = f64_req(p, "y", "text.create")?;
let text = str_param(p, "text").unwrap_or("");
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
if let Some(a) = p.get("area") {
let w = f64_or(a, "width", 200.0);
let h = f64_or(a, "height", 100.0);
+51 -1
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Effect",
[],
None,
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}",
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step) → {ids, index, item, grouped?}",
has_doc,
apply
),
@@ -130,6 +130,15 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
}
let effect = effects::new_effect(id, &params).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
if let Some(loose) = loose_for_pathfinder(s, p, id)? {
let gid = s.edit(&label, |d, sel| {
let gid = super::object::group_nodes(d, &loose)?;
d.node_mut(gid).ok_or(EngineError::NoNode(gid))?.appearance.effects.push(effect.clone());
sel.set([gid]);
Ok(gid)
})?;
return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true }));
}
let item = item_target(s, p, C)?;
let mut index = 0;
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| {
@@ -143,6 +152,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed }))
}
/// The selected objects a Pathfinder effect should group before it applies: it combines the
/// contents of a group or layer, so on several loose objects (none a group) it would do nothing.
/// `None` when the effect isn't one, objects are named (`ids`) or another target is asked for.
fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result<Option<Vec<NodeId>>> {
if !effects::is_pathfinder(id) || ["ids", "id", "item", "target"].iter().any(|k| p.get(k).is_some()) {
return Ok(None);
}
let roots = super::appearance::subject_roots(s)?;
let d = &s.doc()?.doc;
let grouped = roots.iter().any(|r| d.node(*r).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })));
Ok((roots.len() >= 2 && !grouped).then_some(roots))
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let catalog: Vec<Value> = effects::effect_catalog()
.into_iter()
@@ -507,6 +529,34 @@ mod tests {
assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn pathfinder_effect_on_loose_objects_groups_them_first() {
let (mut s, a) = session_with_rect();
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
assert_eq!(r["grouped"], true);
let g = NodeId(r["ids"][0].as_u64().unwrap());
let gn = node(&s, g);
assert_eq!(gn.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), [a, b], "stacking order kept");
assert!(vectorcraft_render::effects::has_pathfinder(&gn));
assert_eq!(s.doc().unwrap().selection.objects, [g]);
// Live: one united shape, so expanding leaves a single path.
s.execute("effect.expandAppearance", &json!({})).unwrap();
assert_eq!(node(&s, g).children().unwrap().len(), 1);
// Grouping and applying are one undo step.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(d.node(g).is_none());
assert!(d.node(a).is_some_and(|n| n.appearance.effects.is_empty()) && d.parent_of(a) == d.parent_of(b));
// A selection holding a group applies to each object as before; so does a single object.
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
assert!(r.get("grouped").is_none());
assert_eq!(node(&s, a).appearance.effects.len(), 1);
}
#[test]
fn pathfinder_effect_on_a_group_renders_live_and_expands() {
let (mut s, a) = session_with_rect();
+13 -7
View File
@@ -375,20 +375,26 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
fn group(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
let gid = s.edit("Group", |d, sel| {
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let gid = d.alloc_id();
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
for id in &ids {
d.move_node(*id, Some(gid), usize::MAX)?;
}
let gid = group_nodes(d, &ids)?;
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": gid.0 }))
}
/// Put `ids` (top-level objects, in paint order) in a new group where the front-most one was.
pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let gid = d.alloc_id();
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
for id in ids {
d.move_node(*id, Some(gid), usize::MAX)?;
}
Ok(gid)
}
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
s.edit("Ungroup", |d, sel| {
+3 -1
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Area / Path Type",
[],
None,
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
has_doc,
create_in_path
),
@@ -368,6 +368,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
};
let kind = if on_path { TextKind::OnPath { path: path.clone(), start } } else { TextKind::Area { frame: path.clone() } };
let mut t = TextObject {
vertical: p.get("vertical").and_then(Value::as_bool).unwrap_or(false),
kind,
xf: Affine::IDENTITY,
runs: vec![TextRun { text, style }],
@@ -404,6 +405,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
let head = edit::slice_runs(&t.runs, 0, end);
let measure = |tr: f64| {
let mut h = TextObject {
vertical: t.vertical,
kind: TextKind::Point,
xf: Affine::IDENTITY,
runs: head.clone(),
+27
View File
@@ -13,6 +13,18 @@ use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("type.orientation.vertical", "Vertical", ["Type", "Orientation"], None, "{ids?|id?} set vertical writing", has_selection, |s, p| {
orientation(s, p, true)
}),
cmd!(
"type.orientation.horizontal",
"Horizontal",
["Type", "Orientation"],
None,
"{ids?|id?} set horizontal writing",
has_selection,
|s, p| orientation(s, p, false)
),
cmd!(
"type.createOutlines",
"Create Outlines",
@@ -61,6 +73,21 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
let ids = text_targets(s, p, "Text Orientation")?;
s.edit("Text Orientation", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else {
return Err(EngineError::NoNode(*id));
};
t.vertical = vertical;
refresh_bounds(t);
}
Ok(())
})?;
Ok(json!({"vertical": vertical, "ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}))
}
/// Recompute the layout bounds cache after a text edit.
pub(crate) fn refresh_bounds(t: &mut TextObject) {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
+50
View File
@@ -310,6 +310,56 @@ fn live_paint_bucket_tool_fills_face() {
assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]);
}
fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> NodeId {
let r = s.execute("shape.line", &json!({"x1": a.0, "y1": a.1, "x2": b.0, "y2": b.1})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
/// Crossing lines, as in the bug report: the Live Paint Bucket fills the area they enclose.
#[test]
fn live_paint_bucket_fills_an_area_enclosed_by_lines() {
let mut s = session();
let ids = [line(&mut s, (0.0, 0.0), (100.0, 100.0)), line(&mut s, (100.0, 0.0), (0.0, 100.0)), line(&mut s, (-10.0, 80.0), (110.0, 80.0))];
s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap();
let v = ViewInfo::default();
s.select_tool("livePaintBucket", v).unwrap();
s.paint.fill = Paint::solid(Color::rgb(1.0, 0.0, 1.0));
// Inside the triangle (20,80) (50,50) (80,80): highlighted, then filled.
s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, 70.0), v).unwrap();
assert!(!s.overlays(v).is_empty(), "red face highlight");
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 70.0), v).unwrap();
let g = s.doc().unwrap().selection.objects[0];
let gn = node(&s, g);
assert!(vectorcraft_tools::builder::is_live_paint(&gn));
assert_eq!(vectorcraft_tools::builder::faces(&gn).len(), 1);
let f = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(50.0, 70.0)).unwrap();
assert_eq!(f.appearance.fill_paint(), s.paint.fill);
assert!((vectorcraft_pathops::area(f.path_data().unwrap(), FillRule::NonZero).abs() - 900.0).abs() < 1e-6);
// The lines are edges, cut where they cross, and keep their stroke.
let edges = vectorcraft_tools::builder::edges(&gn);
assert_eq!(edges.len(), 9);
assert!(edges.iter().all(|e| !e.appearance.stroke_paint().is_none()));
// Outside the triangle there's nothing to fill.
assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 20]})).is_err());
}
#[test]
fn a_line_across_a_live_paint_shape_splits_its_face() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
set_fill(&mut s, r, Color::rgb(1.0, 0.0, 0.0));
let l = line(&mut s, (40.0, -20.0), (60.0, 70.0));
s.execute("select.set", &json!({"ids": [r.0, l.0]})).unwrap();
let made = s.execute("livePaint.make", &json!({})).unwrap();
assert_eq!(made["faces"], 2, "{made}");
let g = NodeId(made["id"].as_u64().unwrap());
s.execute("livePaint.fill", &json!({"group": g.0, "point": [80, 25], "color": "#0000ff"})).unwrap();
let gn = node(&s, g);
let face = |x: f64| vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(x, 25.0)).unwrap().appearance.fill_paint();
assert_eq!(face(10.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "the left half keeps the rectangle's fill");
assert_eq!(face(80.0), Paint::solid(Color::from_hex("#0000ff").unwrap()));
}
// ---------- Image Trace ----------
/// A 100×100 image with a black disc (r = 30 px), placed at (50, 50) scaled ×2.
+19
View File
@@ -378,3 +378,22 @@ fn type_tool_state_does_not_leak_across_documents() {
assert_eq!(obj(&s, id).plain_text(), "First");
assert!(!s.in_interaction());
}
#[test]
fn vertical_text_creation_orientation_and_persistence() {
let mut s = session();
let r = s.execute("text.create", &json!({"x":100,"y":100,"text":"日本語", "vertical":true})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let t = obj(&s, id);
assert!(t.vertical);
let round: TextObject = serde_json::from_value(serde_json::to_value(&t).unwrap()).unwrap();
assert!(round.vertical);
s.execute("type.orientation.horizontal", &json!({"id":id.0})).unwrap();
assert!(!obj(&s, id).vertical);
s.execute("edit.undo", &json!({})).unwrap();
assert!(obj(&s, id).vertical);
let old = serde_json::to_value(TextObject::point(Point::ZERO, "old", Default::default())).unwrap();
assert!(old.get("vertical").is_none());
let old: TextObject = serde_json::from_value(old).unwrap();
assert!(!old.vertical);
}
+10 -1
View File
@@ -1,6 +1,6 @@
# vectorcraft-pathops
Path operations for VectorCraft: booleans, the Pathfinder panel, Shape Builder regions, offset path, outline stroke, simplify and the other Object → Path commands. Everything takes and returns `vectorcraft_geom::PathData`.
Path operations for VectorCraft: booleans, the Pathfinder panel, Shape Builder regions, Live Paint planar maps, offset path, outline stroke, simplify and the other Object → Path commands. Everything takes and returns `vectorcraft_geom::PathData`.
## Booleans (curve-preserving)
@@ -49,6 +49,15 @@ Each result takes its key as follows:
| Crop | Keeps the visible parts of the lower objects inside the front-most object |
| Outline | Returns open edge paths, split at every junction; coincident edges are de-duplicated, and each keeps the front-most key |
## Live Paint
```rust
pub fn live_paint(shapes: &[Shape] /* back → front */) -> (Vec<Region>, Vec<Shape>); // (faces, edges)
pub fn interior_point(path: &PathData) -> Option<Point>;
```
Unlike `regions`, which splits the filled areas of closed shapes, `live_paint` treats every path as an edge, open paths included. Three crossing lines enclose a triangle, a line across a rectangle splits it in two, and an area enclosed by paths becomes a face even when nothing fills it. Each face lists the inputs whose fill covers it; the list is empty for an area that is only enclosed by paths. Each edge is a piece of a path, split wherever another path meets it, and keeps the key of the front-most input it lies on.
## Offset / stroke
```rust
+2 -2
View File
@@ -157,7 +157,7 @@ fn el_points(el: &kurbo::PathEl) -> impl Iterator<Item = Point> {
/// handle within `eps` of its anchor's height is levelled with it (no y-extremum is left in a
/// sub-`eps` sliver of a curve). Anchors move by less than `eps` and handles by less than `2·eps`,
/// the order of the sweep's own tolerance.
fn snap_horizontals(paths: &mut [&mut BezPath], eps: f64) {
pub(crate) fn snap_horizontals(paths: &mut [&mut BezPath], eps: f64) {
use kurbo::PathEl::*;
let mut ys: Vec<f64> = paths.iter().flat_map(|p| p.elements().iter().filter_map(kurbo::PathEl::end_point)).map(|p| p.y).collect();
ys.sort_by(f64::total_cmp);
@@ -339,7 +339,7 @@ pub(crate) fn all_contours_to_path(c: &Contours, tidy: &Tidy) -> PathData {
/// Contours whose mean width (2·area / perimeter) is below `precision` are numerical debris
/// (e.g. the lens left between two coincident curves) and are dropped, like Illustrator's
/// Pathfinder precision setting does.
fn is_sliver(bp: &BezPath, precision: f64) -> bool {
pub(crate) fn is_sliver(bp: &BezPath, precision: f64) -> bool {
let a = bp.area().abs();
let per = bp.perimeter(1e-6);
per <= 0.0 || 2.0 * a / per < precision
+2
View File
@@ -11,6 +11,7 @@ mod edit;
mod fit;
mod offset;
mod pathfinder;
mod planar;
pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_boolean, try_normalize, unite_all};
pub use edit::{
@@ -18,6 +19,7 @@ pub use edit::{
};
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
pub use planar::{interior_point, live_paint};
/// Errors from fallible operations.
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
+815
View File
@@ -0,0 +1,815 @@
//! Live Paint planar maps: the faces and edges a set of paths divides the plane into.
//!
//! Unlike Pathfinder [`regions`](crate::regions), which splits the *filled areas* of closed shapes,
//! every path here is an edge, open ones included: three crossing lines enclose a triangle, a line
//! across a rectangle splits it in two, and an area enclosed by paths is a face whether or not
//! anything fills it.
//!
//! The sweep (`linesweeper`) only takes closed paths, so an open path goes in traced there and
//! back. Its two passes would cancel out as winding numbers, so the sweep counts boundary crossings
//! instead ([`Crossings`]), which never cancel. Faces are then walked on the planar graph of the
//! sweep's output segments; which inputs an edge came from and which fills cover a face are found
//! geometrically (the sweep's winding numbers can't be trusted once open paths and coincident
//! edges are in).
//!
//! The sweep runs in a sheared frame ([`SHEAR`]): it merges the two passes of a *horizontal* open
//! path as if they cancelled out, and in the sheared frame no edge drawn at a common angle (0°,
//! 45°, 90°…) is horizontal. The shear is by a power of two, so it maps typical coordinates
//! exactly and keeps exact coincidences (a corner on another shape's edge) exact.
use std::collections::HashMap;
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, PathSeg, Point, Shape as _};
use linesweeper::topology::{Topology, WindingNumber};
use vectorcraft_geom::{FillRule, PathData};
use crate::boolean::{DEFAULT_PRECISION, Seg, Tidy, eps_for, is_sliver, segs_to_subpath, snap_horizontals, tidy_segments, to_seg};
use crate::pathfinder::{Region, Shape};
/// The sweep's frame: y + x / 32. Only edges sloping -1/32 in the document are horizontal in it.
const SHEAR: Affine = Affine::new([1.0, 1.0 / 32.0, 0.0, 1.0, 0.0, 0.0]);
const UNSHEAR: Affine = Affine::new([1.0, -1.0 / 32.0, 0.0, 1.0, 0.0, 0.0]);
/// Boundary crossings per input, counted from the far west: slot `2i` counts input `i`'s
/// segments running one way, slot `2i + 1` the other. An open path traced there and back adds one
/// to each, so its edges never cancel out of the map.
#[derive(Clone, Debug, Default)]
struct Crossings(Vec<u32>);
impl Crossings {
fn get(&self, slot: usize) -> u32 {
self.0.get(slot).copied().unwrap_or(0)
}
}
impl PartialEq for Crossings {
fn eq(&self, other: &Self) -> bool {
(0..self.0.len().max(other.0.len())).all(|i| self.get(i) == other.get(i))
}
}
impl Eq for Crossings {}
impl std::ops::Add for Crossings {
type Output = Crossings;
fn add(mut self, rhs: Self) -> Crossings {
self += rhs;
self
}
}
impl std::ops::AddAssign for Crossings {
fn add_assign(&mut self, rhs: Self) {
if self.0.len() < rhs.0.len() {
self.0.resize(rhs.0.len(), 0);
}
for (a, b) in self.0.iter_mut().zip(rhs.0) {
*a = a.saturating_add(b);
}
}
}
impl WindingNumber for Crossings {
type Tag = usize;
fn single(tag: usize, positive: bool) -> Self {
let slot = 2 * tag + usize::from(!positive);
let mut v = vec![0; slot + 1];
if let Some(c) = v.get_mut(slot) {
*c = 1;
}
Crossings(v)
}
fn of_tag(&self, tag: usize) -> Self {
let mut v = vec![0; 2 * tag + 2];
for slot in [2 * tag, 2 * tag + 1] {
if let Some(c) = v.get_mut(slot) {
*c = self.get(slot);
}
}
Crossings(v)
}
}
/// A point in the middle of a run of segments.
fn mid(segs: &[Seg]) -> Option<Point> {
segs.get(segs.len() / 2).map(|s| s.c.eval(0.5))
}
/// The inputs' segments (in the sweep's frame), to tell which inputs an edge lies on.
struct Outlines {
segs: Vec<(kurbo::Rect, PathSeg, usize)>,
tol: f64,
}
impl Outlines {
fn new(inputs: &[(BezPath, usize)], eps: f64) -> Self {
let segs = inputs.iter().flat_map(|(bp, i)| bp.segments().map(move |s| (s.bounding_box(), s, *i))).collect();
// The sweep moves points by a few `eps` at most.
Self { segs, tol: 16.0 * eps }
}
/// The front-most input whose outline passes through `p`.
fn source_at(&self, p: Point) -> Option<usize> {
use kurbo::ParamCurveNearest;
let tol = self.tol;
self.segs.iter().filter(|(r, s, _)| r.inflate(tol, tol).contains(p) && s.nearest(p, 1e-9).distance_sq <= tol * tol).map(|(_, _, i)| *i).max()
}
}
/// The area an input's fill covers: its closed subpaths (open ones bound no face).
struct Fill {
path: BezPath,
bounds: kurbo::Rect,
rule: FillRule,
}
impl Fill {
fn new(s: &Shape) -> Self {
let mut path = BezPath::new();
for sp in s.path.subpaths.iter().filter(|sp| sp.closed && sp.anchors.len() >= 2) {
sp.to_bezpath_into(&mut path);
}
Self { bounds: path.bounding_box(), path, rule: s.rule }
}
fn contains(&self, p: Point) -> bool {
!self.path.elements().is_empty() && self.bounds.contains(p) && crate::boolean::inside(self.rule, self.path.winding(p))
}
}
/// One output segment of the sweep: an edge of the planar graph between two vertices.
struct Edge {
segs: Vec<Seg>,
ends: [usize; 2],
/// The input it came from (the front-most, where inputs share it).
source: usize,
}
/// Half-edge `2e` runs along edge `e`, `2e + 1` back along it.
fn twin(h: usize) -> usize {
h ^ 1
}
/// The segments of half-edge `h`, in its direction.
fn half_segs(edges: &[Edge], h: usize) -> Vec<Seg> {
let Some(e) = edges.get(h / 2) else { return Vec::new() };
if h.is_multiple_of(2) {
e.segs.clone()
} else {
e.segs.iter().rev().map(|s| Seg { c: CubicBez::new(s.c.p3, s.c.p2, s.c.p1, s.c.p0), line: s.line }).collect()
}
}
/// The vertex half-edge `h` leaves from.
fn origin(edges: &[Edge], h: usize) -> Option<usize> {
edges.get(h / 2).map(|e| e.ends[h % 2])
}
/// Where half-edge `h` (leaving `v`) first gets `r` away from it, as an angle around `v`.
///
/// Edges that only meet at `v` cross a circle around it in the order they leave `v` in, however
/// they start out (tangent curves, or the sweep's short straight stubs at a vertex).
fn leaving_angle(edges: &[Edge], h: usize, v: Point, r: f64) -> f64 {
let segs = half_segs(edges, h);
let mut bp = BezPath::new();
for (k, s) in segs.iter().enumerate() {
if k == 0 {
bp.move_to(s.c.p0);
}
bp.curve_to(s.c.p1, s.c.p2, s.c.p3);
}
let mut prev = segs.first().map_or(v, |s| s.c.p0);
let mut hit: Option<Point> = None;
kurbo::flatten(bp.iter(), (r * 1e-4).max(1e-12), |el| {
if let (None, kurbo::PathEl::LineTo(p)) = (hit, el) {
if p.distance(v) >= r {
// The point on prev → p at distance r (prev is closer).
let (d, f) = (p - prev, prev - v);
let (a, b, c) = (d.hypot2(), 2.0 * f.dot(d), f.hypot2() - r * r);
let t = if a > 0.0 { ((-b + (b * b - 4.0 * a * c).max(0.0).sqrt()) / (2.0 * a)).clamp(0.0, 1.0) } else { 1.0 };
hit = Some(prev + d * t);
}
prev = p;
}
});
// An edge too short to reach r (it can't, r is at most half of every chord) leaves along its
// tangent.
let far =
hit.unwrap_or_else(|| segs.first().map_or(v, |s| [s.c.p1, s.c.p2, s.c.p3].into_iter().find(|p| p.distance(s.c.p0) > 0.0).unwrap_or(s.c.p3)));
(far - v).atan2()
}
/// Path of a closed run of half-edges, tidied (split pieces of one input curve joined again).
fn cycle_bezpath(edges: &[Edge], cycle: &[usize]) -> BezPath {
let mut bp = BezPath::new();
for (k, s) in cycle.iter().flat_map(|&h| half_segs(edges, h)).enumerate() {
if k == 0 {
bp.move_to(s.c.p0);
}
if s.line {
bp.line_to(s.c.p3);
} else {
bp.curve_to(s.c.p1, s.c.p2, s.c.p3);
}
}
bp.close_path();
bp
}
/// `cycle` without the spurs it runs out and back along (edges with the same face on both sides,
/// such as a line's loose end).
fn without_spurs(cycle: &[usize]) -> Vec<usize> {
let mut out: Vec<usize> = Vec::with_capacity(cycle.len());
for &h in cycle {
if out.last() == Some(&twin(h)) {
out.pop();
} else {
out.push(h);
}
}
// A spur where the walk started wraps around the ends.
while out.len() >= 2 && out.first().map(|&h| twin(h)) == out.last().copied() {
out.pop();
out.remove(0);
}
out
}
/// Append `open` traced there and back (a closed, zero-area path along it) to `out`.
fn there_and_back(open: &BezPath, out: &mut BezPath) {
let segs: Vec<PathSeg> = open.segments().collect();
let Some(first) = segs.first() else { return };
out.move_to(first.start());
for s in &segs {
out.push(s.as_path_el());
}
for s in segs.iter().rev() {
out.push(s.reverse().as_path_el());
}
out.close_path();
}
/// `path` back from the sweep's frame, its anchors within `tol` of an input anchor (`sorted` by x)
/// put exactly on it, handles moving with them.
fn unshear(mut path: PathData, sorted: &[Point], tol: f64) -> PathData {
path.transform(UNSHEAR);
for a in path.subpaths.iter_mut().flat_map(|sp| sp.anchors.iter_mut()) {
let lo = sorted.partition_point(|q| q.x < a.p.x - tol);
if let Some(q) = sorted.get(lo..).unwrap_or_default().iter().take_while(|q| q.x <= a.p.x + tol).find(|q| (q.y - a.p.y).abs() <= tol) {
let d = *q - a.p;
a.p = *q;
a.h_in += d;
a.h_out += d;
}
}
path
}
fn find(parent: &mut [usize], mut v: usize) -> usize {
while let Some(&p) = parent.get(v) {
if p == v {
break;
}
let gp = parent.get(p).copied().unwrap_or(p);
if let Some(slot) = parent.get_mut(v) {
*slot = gp;
}
v = p;
}
v
}
/// The Live Paint planar map of `shapes` (back → front).
///
/// Faces are the bounded areas the paths enclose, holes included, each with the inputs whose fill
/// covers it (ascending; empty for an area only enclosed by paths). Edges are the paths split
/// wherever they meet another path, keyed by the input they came from (the front-most where
/// inputs share one). Empty when the paths can't be swept (non-finite or too degenerate).
pub fn live_paint(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
build(shapes).unwrap_or_default()
}
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
let mut inputs: Vec<(BezPath, usize)> = Vec::new();
for (i, s) in shapes.iter().enumerate() {
let mut bp = BezPath::new();
for sp in s.path.subpaths.iter().filter(|sp| sp.anchors.len() >= 2) {
if sp.closed {
sp.to_bezpath_into(&mut bp);
} else {
let mut open = BezPath::new();
sp.to_bezpath_into(&mut open);
there_and_back(&open, &mut bp);
}
}
if !bp.elements().is_empty() {
bp.apply_affine(SHEAR);
inputs.push((bp, i));
}
}
if inputs.is_empty() {
return None;
}
let eps = eps_for(inputs.iter().map(|p| &p.0)).ok()?;
snap_horizontals(&mut inputs.iter_mut().map(|p| &mut p.0).collect::<Vec<_>>(), eps);
// The sweep can panic on rare near-degenerate input; that must never take the app down.
let sweep =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Topology::<Crossings>::from_paths(inputs.iter().map(|(p, i)| (p, *i)), eps)));
let top = sweep.ok()?.ok()?;
let tidy = Tidy::keeping(DEFAULT_PRECISION, inputs.iter().map(|p| &p.0));
// The planar graph: vertices are the sweep's points, edges its segments. Points closer than
// the sweep's tolerance are one vertex (it can put a corner that lies on another path's edge
// a rounding error away from where it cuts that edge).
let positions = top.compute_positions();
let cell = 4.0 * eps;
let mut grid: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
let mut points: Vec<Point> = Vec::new();
let mut vertex_id = |p: Point| {
let (cx, cy) = ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64);
let near = (cx - 1..=cx + 1)
.flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y)))
.filter_map(|k| grid.get(&k))
.flatten()
.copied()
.find(|&v| points.get(v).is_some_and(|q| q.distance(p) <= eps));
near.unwrap_or_else(|| {
points.push(p);
grid.entry((cx, cy)).or_default().push(points.len() - 1);
points.len() - 1
})
};
let mut edges: Vec<Edge> = Vec::new();
let mut between: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
let outlines = Outlines::new(&inputs, eps);
for idx in top.segment_indices() {
let half = idx.first_half();
let segs: Vec<Seg> = positions[idx].path.segments().map(to_seg).collect();
let ends = [vertex_id(top.point(half).to_kurbo()), vertex_id(top.point(idx.second_half()).to_kurbo())];
if segs.is_empty() || ends[0] == ends[1] {
continue;
}
let Some(source) = mid(&segs).and_then(|m| outlines.source_at(m)) else { continue };
// Coincident pieces the sweep left apart are one edge (the front-most input's).
let key = (ends[0].min(ends[1]), ends[0].max(ends[1]));
let here = mid(&segs);
let twin_of = between.get(&key).and_then(|v| {
v.iter().copied().find(|&e| {
let other = edges.get(e).and_then(|e| mid(&e.segs));
matches!((here, other), (Some(a), Some(b)) if a.distance(b) < DEFAULT_PRECISION)
})
});
if let Some(e) = twin_of {
if let Some(e) = edges.get_mut(e)
&& source > e.source
{
e.source = source;
}
continue;
}
between.entry(key).or_default().push(edges.len());
edges.push(Edge { segs, ends, source });
}
let nv = points.len();
// Half-edges leaving each vertex, sorted by direction; walking a face, the next half-edge is
// the one just clockwise of the way back, so bounded faces come out with positive area and
// the outer boundary of each connected piece with negative area.
let mut around: Vec<Vec<usize>> = vec![Vec::new(); nv];
for h in 0..2 * edges.len() {
if let Some(list) = origin(&edges, h).and_then(|v| around.get_mut(v)) {
list.push(h);
}
}
let mut slot_of = vec![0usize; 2 * edges.len()];
for (v, list) in around.iter_mut().enumerate() {
let Some(&at) = points.get(v) else { continue };
// Half the shortest chord of the edges here: every one of them gets that far from it.
let r = 0.5 * list.iter().filter_map(|&h| half_segs(&edges, h).last().map(|s| s.c.p3.distance(at))).fold(f64::INFINITY, f64::min);
let mut keyed: Vec<(f64, usize)> = list.iter().map(|&h| (leaving_angle(&edges, h, at, r), h)).collect();
keyed.sort_by(|a, b| a.0.total_cmp(&b.0));
*list = keyed.into_iter().map(|(_, h)| h).collect();
for (k, &h) in list.iter().enumerate() {
if let Some(s) = slot_of.get_mut(h) {
*s = k;
}
}
}
let next = |h: usize| -> Option<usize> {
let back = twin(h);
let list = around.get(origin(&edges, back)?)?;
let k = *slot_of.get(back)?;
list.get((k + list.len() - 1) % list.len()).copied()
};
// Connected pieces of the graph.
let mut parent: Vec<usize> = (0..nv).collect();
for e in &edges {
let (a, b) = (find(&mut parent, e.ends[0]), find(&mut parent, e.ends[1]));
if let Some(p) = parent.get_mut(a) {
*p = b;
}
}
let piece: Vec<usize> = (0..nv).map(|v| find(&mut parent, v)).collect();
let piece_of = |cycle: &[usize]| cycle.first().and_then(|&h| origin(&edges, h)).and_then(|v| piece.get(v)).copied();
// Walk every cycle: positive ones are faces, negative ones outer boundaries (holes in the
// face around them, if any).
let mut seen = vec![false; 2 * edges.len()];
let mut faces: Vec<(Vec<usize>, BezPath, f64)> = Vec::new();
let mut holes: Vec<(Vec<usize>, BezPath)> = Vec::new();
for start in 0..2 * edges.len() {
if seen.get(start).copied().unwrap_or(true) {
continue;
}
let mut cycle = vec![];
let mut h = start;
while let Some(s) = seen.get_mut(h).filter(|s| !**s) {
*s = true;
cycle.push(h);
match next(h) {
Some(n) => h = n,
None => break,
}
}
let cycle = without_spurs(&cycle);
if cycle.is_empty() {
continue;
}
let bp = cycle_bezpath(&edges, &cycle);
let area = bp.area();
if area > 0.0 && !is_sliver(&bp, tidy.precision) {
faces.push((cycle, bp, area));
} else if area < -(tidy.precision * tidy.precision) {
holes.push((cycle, bp));
}
}
// Each hole goes in the smallest face of another piece around it.
let mut face_holes: Vec<Vec<usize>> = vec![Vec::new(); faces.len()];
for (k, (cycle, _)) in holes.iter().enumerate() {
let Some(p) = cycle.first().and_then(|&h| origin(&edges, h)).and_then(|v| points.get(v)).copied() else { continue };
let own = piece_of(cycle);
let around_it = faces
.iter()
.enumerate()
.filter(|(_, (fc, bp, _))| piece_of(fc) != own && bp.bounding_box().contains(p) && bp.winding(p) != 0)
.min_by(|a, b| a.1.2.total_cmp(&b.1.2))
.map(|(i, _)| i);
if let Some(list) = around_it.and_then(|i| face_holes.get_mut(i)) {
list.push(k);
}
}
let fills: Vec<Fill> = shapes.iter().map(Fill::new).collect();
// The inputs' anchors, which results land on again up to the sweep's tolerance.
let snap = 2.0 * eps;
let mut anchors: Vec<Point> = shapes.iter().flat_map(|s| s.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect();
anchors.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
let subpath = |cycle: &[usize]| {
let segs: Vec<Seg> = cycle.iter().flat_map(|&h| half_segs(&edges, h)).collect();
segs_to_subpath(&tidy_segments(segs, true, &tidy), true)
};
let regions = faces
.iter()
.zip(&face_holes)
.filter_map(|((cycle, _, _), hs)| {
let mut subpaths = vec![subpath(cycle)?];
subpaths.extend(hs.iter().filter_map(|&k| holes.get(k)).filter_map(|(c, _)| subpath(c)));
let path = unshear(PathData::new(subpaths), &anchors, snap);
let sources =
interior_point(&path).map(|p| fills.iter().enumerate().filter(|(_, f)| f.contains(p)).map(|(i, _)| i).collect()).unwrap_or_default();
Some(Region { path, sources })
})
.collect();
// Edges: chains of segments through vertices where nothing else meets.
let degree: Vec<usize> = around.iter().map(Vec::len).collect();
let passes = |v: usize| -> Option<(usize, usize)> {
let list = around.get(v)?;
let (&a, &b) = (list.first()?, list.get(1)?);
(degree.get(v) == Some(&2) && edges.get(a / 2)?.source == edges.get(b / 2)?.source).then_some((a, b))
};
let mut used = vec![false; edges.len()];
let mut chains: Vec<(Vec<Seg>, bool, usize)> = Vec::new();
// A chain from half-edge `h0` up to the next vertex where it doesn't pass straight through;
// closed when it comes back round to where it started.
let walk = |h0: usize, used: &mut Vec<bool>| -> Option<(Vec<Seg>, bool, usize)> {
let source = edges.get(h0 / 2)?.source;
let mut segs = Vec::new();
let mut h = h0;
loop {
let u = used.get_mut(h / 2)?;
if *u {
return Some((segs, true, source));
}
*u = true;
segs.extend(half_segs(&edges, h));
let v = origin(&edges, twin(h))?;
let Some((a, b)) = passes(v) else { return Some((segs, origin(&edges, h0) == Some(v), source)) };
h = if a == twin(h) { b } else { a };
}
};
for v in 0..nv {
if passes(v).is_some() {
continue;
}
for &h in around.get(v).map(Vec::as_slice).unwrap_or_default() {
if !used.get(h / 2).copied().unwrap_or(true)
&& let Some(c) = walk(h, &mut used)
{
chains.push(c);
}
}
}
// What's left are closed loops with no junction on them.
for e in 0..edges.len() {
if !used.get(e).copied().unwrap_or(true)
&& let Some(c) = walk(2 * e, &mut used)
{
chains.push(c);
}
}
let edge_shapes = chains
.into_iter()
.filter_map(|(segs, closed, source)| {
let sp = segs_to_subpath(&tidy_segments(segs, closed, &tidy), closed)?;
Some(Shape::new(unshear(PathData::new(vec![sp]), &anchors, snap), FillRule::NonZero, shapes.get(source)?.key))
})
.collect();
Some((regions, edge_shapes))
}
/// A point strictly inside a filled path, well away from its edges where it can be (for re-finding
/// a Live Paint face after a rebuild).
pub fn interior_point(path: &PathData) -> Option<Point> {
let bb = path.bounds()?;
let bp = path.to_bezpath();
let mut edges: Vec<(Point, Point)> = vec![];
let (mut first, mut last) = (Point::ZERO, Point::ZERO);
kurbo::flatten(bp.iter(), (bb.width().max(bb.height()) * 1e-3).max(1e-4), |el| match el {
kurbo::PathEl::MoveTo(p) => {
if last != first {
edges.push((last, first));
}
first = p;
last = p;
}
kurbo::PathEl::LineTo(p) => {
edges.push((last, p));
last = p;
}
kurbo::PathEl::ClosePath => {
if last != first {
edges.push((last, first));
}
last = first;
}
_ => {}
});
if last != first {
edges.push((last, first));
}
let mut best: Option<(f64, Point)> = None;
for f in [0.5, 0.25, 0.75, 0.125, 0.375, 0.625, 0.875, 0.0625, 0.9375] {
// A little off the round fractions, which on art drawn to a grid fall on the height of
// some corner, where testing the point against other paths is unreliable.
let y = bb.y0 + bb.height() * (f + 0.0061803);
let mut xs: Vec<f64> =
edges.iter().filter(|(a, c)| (a.y <= y) != (c.y <= y)).map(|(a, c)| a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)).collect();
xs.sort_by(f64::total_cmp);
for w in xs.windows(2) {
let m = Point::new((w[0] + w[1]) / 2.0, y);
let width = w[1] - w[0];
if width > best.map_or(0.0, |b| b.0) && bp.winding(m) != 0 {
best = Some((width, m));
}
}
}
best.map(|b| b.1)
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use vectorcraft_geom::{Rect, SubPath, shapes};
fn line(a: (f64, f64), b: (f64, f64), key: u64) -> Shape {
Shape::new(PathData::single(SubPath::polyline(&[Point::new(a.0, a.1), Point::new(b.0, b.1)], false)), FillRule::NonZero, key)
}
fn rect(x0: f64, y0: f64, x1: f64, y1: f64, key: u64) -> Shape {
Shape::new(shapes::rectangle(Rect::new(x0, y0, x1, y1)), FillRule::NonZero, key)
}
fn area(r: &Region) -> f64 {
r.path.to_bezpath().area()
}
fn at(faces: &[Region], x: f64, y: f64) -> Vec<&Region> {
faces.iter().filter(|f| f.contains(Point::new(x, y))).collect()
}
#[test]
fn crossing_lines_enclose_a_face() {
// Three lines crossing pairwise, their ends sticking out: one triangle.
let s = [line((0.0, 0.0), (100.0, 100.0), 0), line((100.0, 0.0), (0.0, 100.0), 1), line((-10.0, 80.0), (110.0, 80.0), 2)];
let (faces, edges) = live_paint(&s);
assert_eq!(faces.len(), 1, "{faces:?}");
let f = &faces[0];
assert!((area(f) - 900.0).abs() < 1e-6, "triangle (20,80) (50,50) (80,80): {}", area(f));
assert!(f.sources.is_empty(), "nothing fills it yet");
assert_eq!(f.path.subpaths.len(), 1);
assert_eq!(f.path.subpaths[0].anchors.len(), 3, "the loose ends are not part of it");
// Each line is cut where the others cross it: 3 + 3 + 3 pieces.
assert_eq!(edges.len(), 9);
assert_eq!(edges.iter().filter(|e| e.key == 2).count(), 3);
}
#[test]
fn a_grid_of_lines_makes_its_cells() {
let mut s = vec![];
for k in 0..4 {
let v = f64::from(k) * 10.0;
s.push(line((v, -5.0), (v, 35.0), 0));
s.push(line((-5.0, v), (35.0, v), 1));
}
let (faces, _) = live_paint(&s);
assert_eq!(faces.len(), 9);
assert!(faces.iter().all(|f| (area(f) - 100.0).abs() < 1e-6));
assert_eq!(at(&faces, 15.0, 15.0).len(), 1);
}
#[test]
fn a_line_across_a_shape_splits_it() {
let s = [rect(0.0, 0.0, 100.0, 50.0, 7), line((40.0, -20.0), (60.0, 70.0), 8)];
let (faces, edges) = live_paint(&s);
assert_eq!(faces.len(), 2);
assert!(faces.iter().all(|f| f.sources == [0]), "both halves keep the rectangle's fill");
assert!((faces.iter().map(area).sum::<f64>() - 5000.0).abs() < 1e-6);
assert_eq!(at(&faces, 10.0, 25.0).len(), 1);
assert_eq!(at(&faces, 90.0, 25.0).len(), 1);
// The rectangle's outline in two pieces, the line in three.
assert_eq!(edges.iter().filter(|e| e.key == 7).count(), 2);
assert_eq!(edges.iter().filter(|e| e.key == 8).count(), 3);
}
#[test]
fn overlapping_shapes_keep_their_coverage() {
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)];
let (faces, _) = live_paint(&s);
assert_eq!(faces.len(), 3);
assert_eq!(at(&faces, 25.0, 50.0)[0].sources, [0]);
assert_eq!(at(&faces, 75.0, 50.0)[0].sources, [0, 1]);
assert_eq!(at(&faces, 125.0, 50.0)[0].sources, [1]);
// Same faces as the Pathfinder regions.
let regions = crate::regions(&s);
assert_eq!(regions.len(), 3);
for f in &faces {
assert!(regions.iter().any(|r| r.sources == f.sources && (area(r).abs() - area(f)).abs() < 1e-6));
}
}
#[test]
fn a_closed_loop_of_lines_inside_a_shape_is_a_hole_and_a_face() {
let mut s = vec![rect(0.0, 0.0, 100.0, 100.0, 0)];
let sq = [(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0), (40.0, 40.0)];
for w in sq.windows(2) {
s.push(line(w[0], w[1], 1));
}
// A loose line inside: no face of its own, no hole.
s.push(line((10.0, 10.0), (20.0, 20.0), 2));
let (faces, _) = live_paint(&s);
assert_eq!(faces.len(), 2, "{faces:?}");
let outer = at(&faces, 5.0, 5.0);
assert_eq!(outer.len(), 1);
assert_eq!(outer[0].path.subpaths.len(), 2, "the inner square is a hole in it");
assert!((area(outer[0]) - 9600.0).abs() < 1e-6);
let inner = at(&faces, 50.0, 50.0);
assert_eq!(inner.len(), 1, "the hole doesn't count as the outer face");
assert_eq!(inner[0].sources, [0], "the rectangle fills it");
assert!((area(inner[0]) - 400.0).abs() < 1e-6);
}
#[test]
fn an_area_enclosed_by_shapes_is_an_unfilled_face() {
// A frame of four bars around a 50 × 50 gap.
let s = [rect(0.0, 0.0, 70.0, 10.0, 0), rect(0.0, 60.0, 70.0, 70.0, 0), rect(0.0, 0.0, 10.0, 70.0, 0), rect(60.0, 0.0, 70.0, 70.0, 0)];
let (faces, _) = live_paint(&s);
let gap = at(&faces, 35.0, 35.0);
assert_eq!(gap.len(), 1);
assert!(gap[0].sources.is_empty());
assert!((area(gap[0]) - 2500.0).abs() < 1e-6);
}
#[test]
fn curves_and_a_circle_crossed_by_a_line() {
let circle = Shape::new(shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), FillRule::NonZero, 0);
let (faces, edges) = live_paint(&[circle, line((-10.0, 50.0), (110.0, 50.0), 1)]);
assert_eq!(faces.len(), 2);
for f in &faces {
assert!((area(f) - std::f64::consts::PI * 2500.0 / 2.0).abs() < 2.0, "{}", area(f));
assert!(f.path.subpaths[0].anchors.len() <= 4, "no extra anchors from the sweep's splits");
}
assert_eq!(edges.iter().filter(|e| e.key == 0).count(), 2, "two arcs");
}
#[test]
fn a_line_ending_on_an_edge_splits_the_shape() {
let s = [line((90.0, 20.0), (70.0, 10.0), 0), rect(80.0, 10.0, 90.0, 50.0, 1)];
let (faces, _) = live_paint(&s);
assert_eq!(faces.len(), 2);
assert!((area(&faces[0]) + area(&faces[1]) - 400.0).abs() < 1e-6);
for f in &faces {
let p = crate::interior_point(&f.path).unwrap();
assert_eq!(at(&faces, p.x, p.y).len(), 1, "{p:?}");
assert_eq!(f.sources, [1]);
}
}
#[test]
fn nothing_or_garbage_gives_an_empty_map() {
assert_eq!(live_paint(&[]), (vec![], vec![]));
assert!(live_paint(&[line((0.0, 0.0), (f64::NAN, 1.0), 0)]).0.is_empty());
let (faces, edges) = live_paint(&[line((0.0, 0.0), (10.0, 10.0), 0)]);
assert!(faces.is_empty());
assert_eq!(edges.len(), 1);
}
fn arb_segment() -> impl Strategy<Value = Shape> {
let c = || (0..10i32).prop_map(|v| f64::from(v) * 10.0);
prop_oneof![
(c(), c(), c(), c()).prop_map(|(a, b, x, y)| line((a, b), (x, y), 0)),
(c(), c(), 1..5i32, 1..5i32).prop_map(|(x, y, w, h)| rect(x, y, x + f64::from(w) * 10.0, y + f64::from(h) * 10.0, 0)),
(c(), c(), 1..5i32).prop_map(|(x, y, r)| {
let r = f64::from(r) * 10.0;
Shape::new(shapes::ellipse(Rect::new(x, y, x + r, y + r)), FillRule::EvenOdd, 0)
}),
]
}
proptest! {
#![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
/// Faces never overlap, and a face's sources are exactly the inputs whose fill holds a
/// point inside it.
#[test]
fn faces_are_disjoint_and_know_what_covers_them(shapes in prop::collection::vec(arb_segment(), 1..7)) {
let (faces, _) = live_paint(&shapes);
for f in &faces {
prop_assert!(area(f) > 0.0);
let Some(p) = crate::interior_point(&f.path) else { continue };
prop_assert_eq!(faces.iter().filter(|g| g.contains(p)).count(), 1, "{:?} in more than one face", p);
let covering: Vec<usize> = shapes
.iter()
.enumerate()
.filter(|(_, s)| s.path.is_closed() && vectorcraft_geom::hit::fill_contains(&s.path.to_bezpath(), s.rule, p))
.map(|(i, _)| i)
.collect();
prop_assert_eq!(&f.sources, &covering, "at {:?}", p);
}
}
/// Closed shapes alone: the filled faces cover exactly the Pathfinder regions.
#[test]
fn filled_faces_are_the_pathfinder_regions(shapes in prop::collection::vec(arb_segment(), 1..6)) {
let closed: Vec<Shape> = shapes.into_iter().filter(|s| s.path.is_closed()).collect();
let (faces, _) = live_paint(&closed);
let filled: f64 = faces.iter().filter(|f| !f.sources.is_empty()).map(area).sum();
let regions: f64 = crate::regions(&closed).iter().map(|r| area(r).abs()).sum();
// Both refit split curves to [`DEFAULT_PRECISION`], so curved areas may differ by up
// to that much along every outline.
let outlines: f64 = closed.iter().map(|s| s.path.to_bezpath().perimeter(1e-6)).sum();
prop_assert!((filled - regions).abs() <= 1e-9 + DEFAULT_PRECISION * outlines, "{} vs {}", filled, regions);
}
/// Lines anywhere (not on a grid): faces stay disjoint, and nothing panics.
#[test]
fn free_lines_make_disjoint_faces(pts in prop::collection::vec((-50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64), 2..9)) {
let lines: Vec<Shape> = pts.into_iter().map(|(a, b, x, y)| line((a, b), (x, y), 0)).collect();
let (faces, edges) = live_paint(&lines);
prop_assert!(edges.len() >= lines.len().min(1));
for f in &faces {
prop_assert!(f.sources.is_empty());
if let Some(p) = crate::interior_point(&f.path) {
prop_assert_eq!(faces.iter().filter(|g| g.contains(p)).count(), 1);
}
}
}
}
#[test]
fn many_lines_stay_quick() {
// 60 lines through a 100 pt square: about 900 crossings.
let lines: Vec<Shape> = (0..60)
.map(|k| {
let t = f64::from(k) * 0.37;
line((50.0 + 80.0 * t.cos(), 50.0 + 80.0 * t.sin()), (50.0 - 80.0 * (t + 1.1).cos(), 50.0 - 80.0 * (t + 1.1).sin()), 0)
})
.collect();
let started = std::time::Instant::now();
let (faces, _) = live_paint(&lines);
assert!(faces.len() > 500, "{}", faces.len());
assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed());
}
}
+1 -1
View File
@@ -1576,7 +1576,7 @@ impl Writer<'_> {
/// as the canvas paints them, the object's own fills and strokes on the glyph outlines: those
/// below the Characters row under the characters, the others over them.
fn text_node(&mut self, n: &Node, t: &TextObject) {
let chars = |w: &mut Self, n: &Node| if w.opts.outline_text { w.text_outlines(n, t) } else { w.text(n, t) };
let chars = |w: &mut Self, n: &Node| if w.opts.outline_text || t.vertical { w.text_outlines(n, t) } else { w.text(n, t) };
if !n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()) {
return chars(self, n);
}
+74
View File
@@ -0,0 +1,74 @@
//! The optional craft-fonts build input (https://github.com/storytold/craft-fonts,
//! `docs/integration.md`): with `CRAFT_FONTS_DIR=<checkout>` set, every font in its
//! `fonts/manifest.txt` is embedded as `CRAFT_FONTS`; unset, `CRAFT_FONTS` is empty and the app
//! uses its own and the installed fonts. `CRAFT_FONTS_REQUIRED=1` turns a bad checkout into a build
//! error (release builds). Nothing is fetched: the build reads only the local checkout.
//!
//! Web (wasm32) builds embed only the UI font, BIZ UDPGothic Regular (~4.7 MB): the browser has no
//! system fonts, and all four fonts (~24 MB) would push the `.wasm` past static hosts' per-file
//! limits (Cloudflare Pages: 25 MiB).
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
fn main() {
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) {
let dir = from_workspace(dir);
match craft_fonts(&dir) {
Ok(entries) => src.push_str(&entries),
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
}
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
}
}
src.push_str("];\n");
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
if let Err(e) = std::fs::write(&out, src) {
println!("cargo::error=writing {}: {e}", out.display());
}
}
/// A relative `CRAFT_FONTS_DIR` is taken from the workspace root (where `cargo` is usually run),
/// not from this crate's directory, which is where build scripts run.
fn from_workspace(dir: PathBuf) -> PathBuf {
if dir.is_absolute() {
return dir;
}
let crate_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default());
crate_dir.join("../..").join(dir)
}
/// The one craft-fonts face web builds embed (family, style).
const WEB_FONT: (&str, &str) = ("BIZ UDPGothic", "Regular");
/// One `CraftFont { .. }` initialiser per manifest line.
fn craft_fonts(dir: &Path) -> Result<String, String> {
let manifest = dir.join("fonts/manifest.txt");
println!("cargo::rerun-if-changed={}", manifest.display());
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
let mut out = String::new();
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
let [family, style, file, scripts, ..] = f.as_slice() else {
return Err(format!("malformed manifest line: {line}"));
};
if wasm && !(*family == WEB_FONT.0 && *style == WEB_FONT.1) {
continue;
}
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
println!("cargo::rerun-if-changed={}", path.display());
let scripts: Vec<String> = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect();
let _ = writeln!(
out,
" CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({:?}) }},",
scripts.join(", "),
path.display().to_string(),
);
}
Ok(out)
}
+52
View File
@@ -0,0 +1,52 @@
//! Fonts from the optional craft-fonts build input (<https://github.com/storytold/craft-fonts>).
//!
//! Built with `CRAFT_FONTS_DIR=<craft-fonts checkout>`, [`CRAFT_FONTS`] holds every font in its
//! manifest (today the Japanese UI and document fonts); built without it, it is empty and Japanese
//! text falls back to the installed system fonts. Release builds always set it. See `build.rs`
//! and craftrules `standards/fonts.md`.
/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`).
pub struct CraftFont {
pub family: &'static str,
pub style: &'static str,
/// ISO 15924 scripts the font is for, e.g. `"Jpan"`.
pub scripts: &'static [&'static str],
pub bytes: &'static [u8],
}
include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
impl CraftFont {
/// Is the font for Japanese text?
pub fn is_japanese(&self) -> bool {
self.scripts.contains(&"Jpan")
}
/// A serif (Mincho) family, for document text.
pub fn is_mincho(&self) -> bool {
self.family.contains("Mincho")
}
/// A sans (Gothic) family, for UI text.
pub fn is_gothic(&self) -> bool {
self.family.contains("Gothic")
}
/// A bold face.
pub fn is_bold(&self) -> bool {
crate::style_weight(self.style) >= 600.0
}
}
/// The craft-fonts faces for Japanese text, best for document text first: Mincho (serif) families,
/// then the others, regular weights before bold. Empty when built without craft-fonts.
pub fn japanese_document_fonts() -> Vec<&'static CraftFont> {
let mut v: Vec<&'static CraftFont> = CRAFT_FONTS.iter().filter(|f| f.is_japanese()).collect();
v.sort_by_key(|f| (!f.is_mincho(), f.is_bold()));
v
}
/// The craft-fonts faces for Japanese text, best for UI text first: Gothic (sans) families, then
/// the others; `bold` puts bold faces before regular ones. Empty when built without craft-fonts.
pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
let mut v: Vec<&'static CraftFont> = CRAFT_FONTS.iter().filter(|f| f.is_japanese()).collect();
v.sort_by_key(|f| (!f.is_gothic(), f.is_bold() != bold));
v
}
+7
View File
@@ -7,6 +7,8 @@ use vectorcraft_doc::CharStyle;
/// by the character style (`kerning`, `all_caps`, `tracking`); the rest are layout-wide options.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OtFeatures {
/// Vertical glyph alternates (vert/vrt2), set by the writing direction.
pub vertical: bool,
/// Standard ligatures (`liga`, `clig`). Suppressed automatically when tracking is non-zero.
pub ligatures: bool,
/// Contextual alternates (`calt`).
@@ -30,6 +32,7 @@ pub struct OtFeatures {
impl Default for OtFeatures {
fn default() -> Self {
Self {
vertical: false,
ligatures: true,
contextual: true,
discretionary_ligatures: false,
@@ -138,6 +141,10 @@ impl OtFeatures {
v.push(f(tag, true));
}
}
if self.vertical {
v.push(f(b"vert", true));
v.push(f(b"vrt2", true));
}
v
}
}
+4 -1
View File
@@ -383,7 +383,10 @@ impl FontDb {
/// A database holding the bundled fonts, whose system font scan reads `font_dirs`.
pub fn with_font_dirs(font_dirs: Vec<PathBuf>) -> Self {
let mut faces = Vec::new();
for data in BUNDLED {
// The bundled fonts, then the Japanese craft-fonts faces (when built with them), Mincho
// first: fallbacks for Japanese text after the requested and bundled fonts.
let craft = crate::craft_fonts::japanese_document_fonts().into_iter().map(|f| f.bytes);
for data in BUNDLED.iter().copied().chain(craft) {
for (i, family, style) in enumerate_faces(data) {
if let Some(f) = make_face(FontBytes::Static(data), i, family, style, None) {
faces.push(Arc::new(f));
+49 -4
View File
@@ -14,6 +14,8 @@ use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, PositionedGlyph, T
const EPS: f64 = 1e-6;
struct Ctx<'a> {
vertical: bool,
on_path: bool,
db: &'a FontDb,
text: &'a str,
runs: Vec<(Range<usize>, &'a CharStyle)>,
@@ -37,7 +39,22 @@ impl Ctx<'_> {
fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) {
let src = self.db.outline(&g.face, g.gid);
let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy);
let m = pre * local;
let mut m = pre * local;
let mut origin = origin;
let mut angle = angle;
if self.vertical {
let writing = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
if self.on_path {
// Vertical path type keeps the baseline path and turns each glyph across it.
m = Affine::translate(origin.to_vec2()) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::translate(-origin.to_vec2()) * m;
} else {
let physical = writing * origin;
let upright = matches!(g.ch as u32, 0x3000..=0x30ff | 0x3400..=0x9fff | 0xf900..=0xfaff | 0xfe10..=0xfe4f | 0xff01..=0xff60 | 0x20000..=0x3134f);
m = if upright { Affine::translate((physical - origin) + Vec2::new(-g.descent, g.ascent)) * m } else { writing * m };
origin = physical;
angle += std::f64::consts::FRAC_PI_2;
}
}
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef).
let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() {
BezPath::new()
@@ -99,16 +116,43 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
}
}
paras.push(s..text.len());
let mut cx = Ctx { db, text: &text, runs, default: CharStyle::default(), opts, out: TextLayout::default() };
let mut vertical_opts = opts.clone();
vertical_opts.features.vertical = t.vertical;
let opts = &vertical_opts;
let is_on_path = matches!(&t.kind, TextKind::OnPath { .. });
let mut cx = Ctx {
vertical: t.vertical,
on_path: is_on_path,
db,
text: &text,
runs,
default: CharStyle::default(),
opts,
out: TextLayout { vertical: t.vertical && !is_on_path, ..Default::default() },
};
match &t.kind {
TextKind::Point => flow(&mut cx, &paras, &t.para, None),
TextKind::Area { frame } => {
let regions = Region::cells(&frame.to_bezpath(), opts, &t.wrap);
let inverse = Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0]);
let frame = if t.vertical { frame.transformed(inverse) } else { frame.clone() };
let mut wrap = t.wrap.clone();
if t.vertical {
for w in &mut wrap {
w.path.transform(inverse);
}
}
let regions = Region::cells(&frame.to_bezpath(), opts, &wrap);
cx.out.frames = regions.iter().map(|r| r.cell).collect();
flow(&mut cx, &paras, &t.para, Some(&regions));
}
TextKind::OnPath { path, start } => on_path(&mut cx, &paras, &t.para, &path.to_bezpath(), *start, path.is_closed(), t.path_effect),
}
if cx.out.vertical {
let writing = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
for frame in &mut cx.out.frames {
*frame = writing.transform_rect_bbox(*frame);
}
}
finish_bounds(&mut cx.out);
cx.out
}
@@ -123,7 +167,8 @@ fn finish_bounds(out: &mut TextLayout) {
}
if !out.on_path {
for l in &out.lines {
add(Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent));
let r = Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent);
add(if out.vertical { Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]).transform_rect_bbox(r) } else { r });
}
}
out.bounds = b.unwrap_or_default();
+18 -6
View File
@@ -10,6 +10,7 @@
#![forbid(unsafe_code)]
mod composer;
pub mod craft_fonts;
pub mod edit;
pub mod embed;
mod features;
@@ -19,6 +20,7 @@ mod layout;
mod shape;
pub mod thread;
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
pub use features::OtFeatures;
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, style_weight, system_font_dirs};
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
@@ -124,6 +126,8 @@ pub struct LineInfo {
#[derive(Clone, Debug, Default)]
pub struct TextLayout {
/// Lines retain inline/block coordinates; glyph geometry is in physical text space.
pub vertical: bool,
pub glyphs: Vec<PositionedGlyph>,
pub lines: Vec<LineInfo>,
/// Ink/advance bounds in text space.
@@ -138,6 +142,13 @@ pub struct TextLayout {
}
impl TextLayout {
/// Convert physical text coordinates to inline/block coordinates.
pub fn logical_point(&self, p: Point) -> Point {
if self.vertical { Point::new(p.y, -p.x) } else { p }
}
pub fn physical_point(&self, p: Point) -> Point {
if self.vertical { Point::new(-p.y, p.x) } else { p }
}
/// All glyph outlines combined (e.g. for Create Outlines).
pub fn to_bezpath(&self) -> BezPath {
let mut p = BezPath::new();
@@ -192,7 +203,7 @@ pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) {
} else if let Some(g) = glyphs.last() {
(g.origin + dir(g.angle) * g.advance, g.angle)
} else {
(Point::new(line.x0, line.baseline), 0.0)
(layout.physical_point(Point::new(line.x0, line.baseline)), if layout.vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 })
};
// Up vector in y-down space, rotated with the baseline.
let d = dir(angle);
@@ -214,6 +225,7 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
None => layout.lines.first().map_or(0, |l| l.start),
};
}
let p = layout.logical_point(p);
// Nearest line band (vertical distance first, then distance to the line's column).
let dist = |l: &LineInfo| {
let dy = if p.y < l.baseline - l.ascent {
@@ -247,7 +259,7 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize {
let Some(line) = layout.lines.get(li) else { return 0 };
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
if let Some(g) = glyphs.iter().find(|g| x < g.origin.x + g.advance * 0.5) {
if let Some(g) = glyphs.iter().find(|g| x < layout.logical_point(g.origin).x + g.advance * 0.5) {
return g.byte;
}
line_end(layout, li)
@@ -281,9 +293,9 @@ fn x_in_line(layout: &TextLayout, li: usize, byte: usize) -> f64 {
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
if let Some(g) = glyphs.iter().find(|g| g.byte + g.len > byte) {
let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 };
return g.origin.x + g.advance * frac;
return layout.logical_point(g.origin).x + g.advance * frac;
}
glyphs.last().map_or(line.x0, |g| g.origin.x + g.advance)
glyphs.last().map_or(line.x0, |g| layout.logical_point(g.origin).x + g.advance)
}
/// Move the caret `delta` lines up (negative) or down, keeping horizontal position `goal_x`
@@ -345,14 +357,14 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
let mut xb = x_in_line(layout, li, b.min(l.end));
if b > l.end {
// The selection continues past the line: include the line break / trailing space.
let last = layout.glyphs[l.glyph_start..l.glyph_end].last().map_or(l.x1, |g| g.origin.x + g.advance);
let last = layout.glyphs[l.glyph_start..l.glyph_end].last().map_or(l.x1, |g| layout.logical_point(g.origin).x + g.advance);
xb = last.max(l.x1) + (l.ascent + l.descent) * 0.25;
}
if xb - xa <= 1e-9 {
continue;
}
let (t, bt) = (l.baseline - l.ascent, l.baseline + l.descent);
out.push([Point::new(xa, t), Point::new(xb, t), Point::new(xb, bt), Point::new(xa, bt)]);
out.push([Point::new(xa, t), Point::new(xb, t), Point::new(xb, bt), Point::new(xa, bt)].map(|p| layout.physical_point(p)));
}
out
}
+70
View File
@@ -421,3 +421,73 @@ fn tab_stops_position_text() {
p.para.tabs = vec![stop(50.0, TabAlign::Left)];
assert!((x_of(&p, 4) - 72.0).abs() < 1e-6);
}
/// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when
/// they aren't: the bundled fonts have none, and [`db`] reads no system fonts.
fn japanese_fonts() -> bool {
let built_in = CRAFT_FONTS.iter().any(|f| f.is_japanese());
if !built_in {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
}
built_in
}
#[test]
fn japanese_text_uses_the_craft_fonts_mincho_after_the_bundled_fonts() {
if !japanese_fonts() {
return;
}
let l = layout(db(), &point("日本語の文字", style(20.0)));
assert_eq!(l.glyphs.len(), 6);
assert!(l.glyphs.iter().all(|g| g.gid != 0 && !g.outline.elements().is_empty()), "real glyphs, no tofu");
let face = db().face_covering('日').unwrap();
assert!(face.family.contains("Mincho"), "document text falls back to a Mincho face: {}", face.family);
assert!(CRAFT_FONTS.iter().any(|f| f.family == face.family));
// Latin keeps the bundled fallback family.
assert_eq!(db().face_covering('a').unwrap().family, FALLBACK_FAMILY);
}
#[test]
fn japanese_text_lays_out_without_the_craft_fonts() {
// Built either way, Japanese text lays out (as missing glyphs when no font has them).
let l = layout(db(), &point("日本語の文字 abc", style(20.0)));
assert_eq!(l.lines.len(), 1);
assert!(l.glyphs.iter().all(|g| g.advance.is_finite()));
if CRAFT_FONTS.is_empty() {
assert!(db().families().iter().all(|f| !f.contains("Mincho") && !f.contains("Gothic")), "no Japanese font is bundled");
}
}
#[test]
fn vertical_japanese_columns_have_upright_ink_and_edit_geometry() {
if !japanese_fonts() {
return;
}
let mut t = point("日本語\n縦書き", style(30.0));
t.vertical = true;
let l = layout(db(), &t);
assert!(l.vertical);
assert_eq!(l.lines.len(), 2);
assert!(l.glyphs.iter().all(|g| g.gid != 0 && !g.outline.elements().is_empty()));
assert!(l.glyphs[3].origin.x < l.glyphs[0].origin.x);
assert!(l.glyphs[1].origin.y > l.glyphs[0].origin.y);
for g in &l.glyphs {
let p = g.origin + dir(g.angle) * (g.advance * 0.1);
assert_eq!(hit_byte(&l, p), g.byte);
let (a, b) = caret_position(&l, g.byte);
assert!((a.y - b.y).abs() < 1e-6);
assert!(a.x > b.x);
}
let selection = selection_quads(&l, 0, "日本語".len());
assert_eq!(selection.len(), 1);
}
#[test]
fn vertical_area_type_wraps_into_columns_inside_the_frame() {
let mut t = area("日本語日本語日本語", style(20.0), Rect::new(0.0, 0.0, 100.0, 65.0), Justify::Left);
t.vertical = true;
let l = layout(db(), &t);
assert!(l.lines.len() > 1);
assert!(l.glyphs.iter().all(|g| g.origin.x >= 0.0 && g.origin.x <= 100.0 && g.origin.y >= 0.0 && g.origin.y <= 65.0));
assert!(l.glyphs[l.lines[1].glyph_start].origin.x < l.glyphs[0].origin.x);
}
+20 -3
View File
@@ -243,9 +243,15 @@ pub fn hatch(path: &BezPath, gap: f64) -> BezPath {
struct RegionCache {
key: Option<(usize, Vec<NodeId>)>,
regions: Vec<(po::Region, BezPath, Rect)>,
/// Live Paint faces (every path an edge, open ones included) rather than Shape Builder
/// regions (the filled areas of the shapes).
live: bool,
}
impl RegionCache {
fn live_paint() -> Self {
Self { live: true, ..Default::default() }
}
fn get(&mut self, cx: &ToolContext) -> &[(po::Region, BezPath, Rect)] {
let key = (cx.doc as *const Document as usize, cx.selection.objects.clone());
if self.key.as_ref() != Some(&key) {
@@ -254,7 +260,8 @@ impl RegionCache {
self.regions = if shapes.is_empty() {
vec![]
} else {
po::regions(&shapes)
let regions = if self.live { po::live_paint(&shapes).0 } else { po::regions(&shapes) };
regions
.into_iter()
.filter_map(|r| {
let bp = r.path.to_bezpath();
@@ -437,12 +444,17 @@ fn lp_overlays(h: &LpHover, cache: &RegionCache) -> Vec<Overlay> {
/// Live Paint Bucket: click fills the face under the cursor with the current fill; Shift-click
/// paints the nearest edge with the current stroke. Clicking selected ordinary paths makes them a
/// Live Paint group first.
#[derive(Default)]
pub struct LivePaintBucketTool {
cache: RegionCache,
hover: LpHover,
}
impl Default for LivePaintBucketTool {
fn default() -> Self {
Self { cache: RegionCache::live_paint(), hover: LpHover::None }
}
}
impl Tool for LivePaintBucketTool {
fn id(&self) -> &'static str {
"livePaintBucket"
@@ -500,12 +512,17 @@ impl Tool for LivePaintBucketTool {
// ---------- Live Paint Selection ----------
/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge.
#[derive(Default)]
pub struct LivePaintSelectionTool {
cache: RegionCache,
hover: LpHover,
}
impl Default for LivePaintSelectionTool {
fn default() -> Self {
Self { cache: RegionCache::live_paint(), hover: LpHover::None }
}
}
impl Tool for LivePaintSelectionTool {
fn id(&self) -> &'static str {
"livePaintSelection"
+1 -1
View File
@@ -414,7 +414,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
"directSelection" => Box::new(direct::DirectSelectionTool::new(false)),
"groupSelection" => Box::new(direct::DirectSelectionTool::new(true)),
"pen" => Box::new(pen::PenTool::default()),
"type" | "areaType" | "typeOnPath" => Box::new(text::TypeTool::new(id)),
"type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath" => Box::new(text::TypeTool::new(id)),
"rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "lineSegment" => Box::new(shape::ShapeTool::new(id)),
// Not in the toolbar: `file.place.queue` loads it.
"place" => Box::new(place::PlaceTool::default()),
+29 -10
View File
@@ -40,6 +40,7 @@ struct Typing {
#[derive(Default)]
pub struct TypeTool {
vertical: bool,
mode: Mode,
/// Text object being edited.
editing: Option<NodeId>,
@@ -65,11 +66,11 @@ pub struct TypeTool {
impl TypeTool {
pub fn new(id: &str) -> Self {
let mode = match id {
"areaType" => Mode::Area,
"typeOnPath" => Mode::OnPath,
"areaType" | "verticalAreaType" => Mode::Area,
"typeOnPath" | "verticalTypeOnPath" => Mode::OnPath,
_ => Mode::Type,
};
Self { mode, ..Default::default() }
Self { mode, vertical: id.starts_with("vertical"), ..Default::default() }
}
fn text<'a>(cx: &'a ToolContext, id: NodeId) -> Option<&'a TextObject> {
@@ -236,16 +237,20 @@ impl TypeTool {
let on_path = self.mode == Mode::OnPath;
if let Some(pid) = Self::path_at(cx, start, !on_path) {
let mode = if on_path { "onPath" } else { "area" };
out.push(Action::Exec("text.createInPath".into(), json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y]})));
out.push(Action::Exec(
"text.createInPath".into(),
json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y], "vertical": self.vertical}),
));
out.push(Action::Notify("text.editNew".into()));
return out;
}
}
let area = drag.map(|d| Rect::from_points(start, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
let params = match area {
let mut params = match area {
Some(r) => json!({"x": r.x0, "y": r.y0 + 12.0, "text": "", "area": {"width": r.width(), "height": r.height()}}),
None => json!({"x": start.x, "y": start.y, "text": ""}),
};
params["vertical"] = json!(self.vertical);
out.push(Action::Exec("text.create".into(), params));
out.push(Action::Notify("text.editNew".into()));
out
@@ -294,10 +299,13 @@ fn common_affixes(a: &str, b: &str) -> (usize, usize) {
impl Tool for TypeTool {
fn id(&self) -> &'static str {
match self.mode {
Mode::Type => "type",
Mode::Area => "areaType",
Mode::OnPath => "typeOnPath",
match (self.mode, self.vertical) {
(Mode::Type, false) => "type",
(Mode::Type, true) => "verticalType",
(Mode::Area, false) => "areaType",
(Mode::Area, true) => "verticalAreaType",
(Mode::OnPath, false) => "typeOnPath",
(Mode::OnPath, true) => "verticalTypeOnPath",
}
}
fn busy(&self) -> bool {
@@ -397,6 +405,17 @@ impl Tool for TypeTool {
let (a, b) = self.sel();
let word = mods.cmd || mods.alt;
let lay = self.layout(&t);
let key = if lay.vertical {
match key {
ToolKey::Up => ToolKey::Left,
ToolKey::Down => ToolKey::Right,
ToolKey::Right => ToolKey::Up,
ToolKey::Left => ToolKey::Down,
other => other,
}
} else {
key
};
let vertical = matches!(key, ToolKey::Up | ToolKey::Down);
if !vertical {
self.goal_x = None;
@@ -437,7 +456,7 @@ impl Tool for TypeTool {
ToolKey::Up | ToolKey::Down => {
let d = if key == ToolKey::Up { -1 } else { 1 };
let from = if a != b && !mods.shift { if d < 0 { a } else { b } } else { self.caret };
let x = self.goal_x.unwrap_or_else(|| vectorcraft_text::caret_position(&lay, from).0.x);
let x = self.goal_x.unwrap_or_else(|| lay.logical_point(vectorcraft_text::caret_position(&lay, from).0).x);
let to = if mods.cmd {
// Cmd+Up/Down: paragraph start / end.
let p = edit::paragraph_at(&text, from);
+9
View File
@@ -218,3 +218,12 @@ fn area_and_path_tools_convert_clicked_paths() {
let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 100.0));
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, p) if cmd == "text.createInPath" && p["mode"] == "onPath")), "{acts:?}");
}
#[test]
fn vertical_tools_preserve_their_ids_and_create_vertical_text() {
for id in ["verticalType", "verticalAreaType", "verticalTypeOnPath"] {
let tool = TypeTool::new(id);
assert_eq!(tool.id(), id);
assert!(tool.vertical);
}
}
+153
View File
@@ -0,0 +1,153 @@
//! Interface translations. Command ids, document text and file names remain stable.
//! Untranslated labels fall back to English so coverage can grow incrementally. Every entry is a
//! label VectorCraft's menus show (tested), so labels from other apps don't creep in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Language {
#[default]
En,
Ja,
}
impl Language {
pub const ALL: [Self; 2] = [Self::En, Self::Ja];
pub fn name(self) -> &'static str {
match self {
Self::En => "English",
Self::Ja => "日本語",
}
}
pub fn parse(code: &str) -> Option<Self> {
match code {
"en" => Some(Self::En),
"ja" => Some(Self::Ja),
_ => None,
}
}
pub fn tr(self, text: &str) -> &str {
if self == Self::Ja
&& let Some((_, japanese)) = JAPANESE.iter().find(|(english, _)| *english == text)
{
return japanese;
}
text
}
}
const JAPANESE: &[(&str, &str)] = &[
("Object", "オブジェクト"),
("Effect", "効果"),
("Settings…", "環境設定…"),
("Type", "書式"),
("Select", "選択"),
("Window", "ウィンドウ"),
("Language", "表示言語"),
("Save As…", "別名で保存…"),
("New…", "新規…"),
("Horizontal", "横書き"),
("Vertical", "縦書き"),
("Type Orientation", "組み方向"),
("Layers", "レイヤー"),
("History", "履歴"),
("Properties", "プロパティ"),
("Color", "カラー"),
("Tools", "ツール"),
("Zoom In", "ズームイン"),
("Zoom Out", "ズームアウト"),
("Copy", "コピー"),
("Cut", "切り取り"),
("Paste", "貼り付け"),
("Deselect", "選択を解除"),
("Export", "書き出し"),
("Export As…", "形式を指定して書き出し…"),
("File", "ファイル"),
("Edit", "編集"),
("View", "表示"),
("Help", "ヘルプ"),
("Preferences…", "環境設定…"),
("Open…", "開く…"),
("Save", "保存"),
("Revert", "保存済みの状態に戻す"),
("Print…", "印刷…"),
("Undo", "取り消し"),
("Redo", "やり直し"),
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn translations_are_unique_and_preserve_unknown_text() {
for (i, (en, ja)) in JAPANESE.iter().enumerate() {
assert!(!ja.is_empty());
assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other));
assert_eq!(Language::En.tr(en), *en);
}
assert_eq!(Language::Ja.tr("File"), "ファイル");
assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf");
assert_eq!(Language::parse("xx"), None);
}
#[test]
fn every_translated_label_is_a_menu_label() {
fn walk(items: &[crate::menus::Item], out: &mut Vec<&'static str>) {
for i in items {
match i {
crate::menus::Item::Cmd(l, ..) | crate::menus::Item::Todo(l, _) | crate::menus::Item::Header(l) => out.push(l),
crate::menus::Item::Sub(l, children) => {
out.push(l);
walk(children, out);
}
crate::menus::Item::Sep => {}
}
}
}
let mut labels = vec![];
for (title, items) in crate::menus::menu_tree() {
labels.push(title);
walk(&items, &mut labels);
}
for (en, _) in JAPANESE {
assert!(labels.contains(en), "`{en}` isn't a VectorCraft menu label");
}
}
#[test]
fn language_command_validates_and_persists_without_a_document() {
let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
crate::menus::run_ui_command(&mut app, "app.language", &serde_json::json!({"lang": "ja"})).unwrap().unwrap();
assert_eq!(app.ui.language, Language::Ja);
assert_eq!(crate::menus::checked(&app, "app.language", &serde_json::json!({"lang": "ja"})), Some(true));
assert!(crate::menus::run_ui_command(&mut app, "app.language", &serde_json::json!({"lang": "xx"})).unwrap().is_err());
assert_eq!(app.ui.language, Language::Ja);
let saved = serde_json::to_string(&app.ui).unwrap();
let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap();
assert_eq!(restored.language, Language::Ja);
}
#[test]
fn japanese_glyphs_are_available_without_system_fonts() {
if !vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.is_japanese()) {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
return;
}
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
output.textures_delta.clear();
ctx.fonts_mut(|fonts| {
let families: Vec<_> = fonts.definitions().families.keys().cloned().collect();
for family in families {
let font = egui::FontId::new(13.0, family);
for ch in "日本語ファイル編集".chars() {
assert!(fonts.has_glyph(&font, ch), "missing {ch} in {font:?}");
}
}
});
}
}
+1
View File
@@ -15,6 +15,7 @@ pub mod cursors;
pub mod dialogs;
pub mod dock;
pub mod find_font;
pub mod i18n;
pub mod icon_data;
pub mod icons;
pub mod io;
+26 -6
View File
@@ -50,6 +50,7 @@ use Item::Sep;
/// UI-level commands: (id, label, shortcut, params doc).
pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("app.language", "Interface Language", "", "{lang: en|ja} — persistent interface language"),
("file.open", "Open…", "Cmd+O", "{path?}"),
(
"file.save",
@@ -671,6 +672,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
/// Handle a UI command. `None` = not a UI command (the engine handles it).
pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
if id == "app.language" {
let language = p.get("lang").and_then(Value::as_str).and_then(crate::i18n::Language::parse);
let Some(language) = language else { return Some(Err("lang must be en or ja".into())) };
app.ui.language = language;
return Some(Ok(json!(language)));
}
if let Some(r) = crate::panels::character::intercept_text_command(app, id) {
return Some(r);
}
@@ -1102,8 +1109,19 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
/// Checked state for toggle items.
pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
if id == "app.language" {
return Some(p.get("lang").and_then(Value::as_str).and_then(crate::i18n::Language::parse) == Some(app.ui.language));
}
let v = &app.ui.view;
Some(match id {
"type.orientation.vertical" | "type.orientation.horizontal" => {
let st = app.session.active()?;
let t = st.selection.objects.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) {
Some(vectorcraft_engine::doc::NodeKind::Text(t)) => Some(t),
_ => None,
})?;
t.vertical == (id == "type.orientation.vertical")
}
"view.outline" => v.outline,
"view.pixelPreview" => v.pixel_preview,
"view.trimView" => v.trim_view,
@@ -1432,6 +1450,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
c("Join Our Discord", "help.discord"),
Sep,
c("Settings…", "edit.preferences"),
sub("Language", vec![cp("English", "app.language", json!({"lang": "en"})), cp("日本語", "app.language", json!({"lang": "ja"}))]),
Sep,
sub("UI Brightness", Brightness::ALL.iter().map(|b| cp(b.label(), "window.brightness", json!({"brightness": b.id()}))).collect()),
Sep,
@@ -1837,7 +1856,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
c("Fill with Placeholder Text", "type.fillPlaceholder"),
Sep,
c("Show Hidden Characters", "type.hiddenCharacters"),
sub("Type Orientation", vec![todo("Horizontal"), todo("Vertical")]),
sub("Type Orientation", vec![c("Horizontal", "type.orientation.horizontal"), c("Vertical", "type.orientation.vertical")]),
],
),
(
@@ -2094,9 +2113,9 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
.ui(ui, |ui| {
for (i, (title, items)) in tree.iter().enumerate() {
let text = if i == 0 {
egui::RichText::new(*title).font(theme::semibold(13.0)).color(t.text)
egui::RichText::new(app.ui.language.tr(title)).font(theme::semibold(13.0)).color(t.text)
} else {
egui::RichText::new(*title).size(13.0).color(t.text)
egui::RichText::new(app.ui.language.tr(title)).size(13.0).color(t.text)
};
ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked));
}
@@ -2126,10 +2145,10 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked
ui.separator();
}
Item::Header(h) => {
ui.label(egui::RichText::new(*h).size(11.0).color(t.text_dim));
ui.label(egui::RichText::new(app.ui.language.tr(h)).size(11.0).color(t.text_dim));
}
Item::Sub(label, children) => {
ui.menu_button(*label, |ui| {
ui.menu_button(app.ui.language.tr(label), |ui| {
widgets::menu_scroll(ui, |ui| {
ui.set_min_width(200.0);
render_items(app, ui, children, clicked);
@@ -2138,7 +2157,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked
}
Item::Todo(label, sc) => {
ui.add_enabled_ui(false, |ui| {
ui.add(egui::Button::new(*label).shortcut_text(pretty_shortcut(sc)));
ui.add(egui::Button::new(app.ui.language.tr(label)).shortcut_text(pretty_shortcut(sc)));
})
.response
.on_disabled_hover_text("Coming soon — tracked in the parity plan");
@@ -2150,6 +2169,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked
continue;
}
let label = dynamic_label(app, id, label);
let label = app.ui.language.tr(&label).to_string();
let sc = item_shortcut(id, p).map(pretty_shortcut).unwrap_or_default();
let chk = checked(app, id, p);
let text = match chk {
+3
View File
@@ -207,6 +207,8 @@ impl Dialog {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct UiState {
#[serde(default)]
pub language: crate::i18n::Language,
pub brightness: Brightness,
pub dock_tab: DockTab,
/// Icon panel currently popped out of the collapsed column.
@@ -342,6 +344,7 @@ impl UiState {
impl Default for UiState {
fn default() -> Self {
Self {
language: crate::i18n::Language::default(),
brightness: Brightness::MediumDark,
dock_tab: DockTab::Properties,
open_panel: None,
+19
View File
@@ -254,9 +254,28 @@ pub fn install_fonts(ctx: &egui::Context) {
let mut mono = vec!["JetBrainsMono".to_string()];
mono.extend(fallback);
fonts.families.insert(FontFamily::Name(FONT_MONO.into()), mono);
add_craft_fonts(&mut fonts);
ctx.set_fonts(fonts);
}
/// The egui name of a craft-fonts face.
pub(crate) fn craft_font_name(f: &vectorcraft_text::CraftFont) -> String {
format!("craft-fonts {} {}", f.family, f.style)
}
/// The Japanese craft-fonts faces (when built with `CRAFT_FONTS_DIR`) as fallbacks at the end of
/// every family, after the app's own fonts and before the installed fonts `ui_fonts` adds: BIZ
/// UDPGothic first (bold first in the semibold family), then the Mincho faces.
fn add_craft_fonts(fonts: &mut FontDefinitions) {
for f in vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.is_japanese()) {
fonts.font_data.insert(craft_font_name(f), Arc::new(FontData::from_static(f.bytes)));
}
for (family, stack) in fonts.families.iter_mut() {
let bold = *family == FontFamily::Name(FONT_UI_SEMIBOLD.into());
stack.extend(vectorcraft_text::craft_fonts::japanese_ui_fonts(bold).into_iter().map(craft_font_name));
}
}
/// Apply tokens to egui's global style.
pub fn apply(ctx: &egui::Context, b: Brightness) {
let t = Tokens::for_brightness(b);
+48 -2
View File
@@ -1,10 +1,12 @@
//! Fallback fonts for the app's own text: layer and file names (and anything else the UI shows) in
//! Chinese, Japanese, Korean or other scripts the UI fonts lack are drawn in an installed font that
//! covers them. Nothing is bundled for them: the first time a frame paints a character the UI fonts
//! covers them. Builds made with craft-fonts (`CRAFT_FONTS_DIR`, all releases) carry Japanese fonts,
//! which `theme::install_fonts` adds after the UI fonts, so Japanese never gets here; nothing else
//! is bundled for them: the first time a frame paints a character the UI fonts
//! don't have, an installed font covering it is looked for (in the background on native) and added
//! to egui's families as their last fallback, from the next frame on. Text the UI fonts cover costs
//! no font loading or memory. On the web, which has no system fonts, such characters stay
//! missing-glyph boxes.
//! missing-glyph boxes (Japanese too, unless built with craft-fonts).
use std::collections::HashSet;
use std::sync::Arc;
@@ -171,6 +173,50 @@ mod tests {
assert!(fonts.pending.is_none() && fonts.added.is_empty());
}
#[test]
fn japanese_renders_with_the_craft_fonts_without_installed_fonts() {
if !vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.is_japanese()) {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
return;
}
let text = "日本語の文字";
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut fonts = UiFonts::default();
frame(&ctx, &mut fonts, text);
// Real glyphs (no tofu) in every family from the first frame, with no installed font looked for.
assert!(fonts.pending.is_none() && fonts.added.is_empty(), "no system-font search");
let families: Vec<FontFamily> = ctx.fonts(|f| f.definitions().families.keys().cloned().collect());
for family in families {
let stack = ctx.fonts(|f| f.definitions().families.get(&family).cloned().unwrap_or_default());
assert!(stack.last().is_some_and(|n| n.starts_with("craft-fonts ")), "{family:?}: craft-fonts last: {stack:?}");
for c in text.chars() {
assert!(ctx.fonts_mut(|f| f.has_glyph(&FontId::new(13.0, family.clone()), c)), "{family:?} draws {c}");
}
}
// The UI prefers BIZ UDPGothic.
let stack = ctx.fonts(|f| f.definitions().families.get(&FontFamily::Proportional).cloned().unwrap_or_default());
let first = stack.iter().find(|n| n.starts_with("craft-fonts ")).cloned().unwrap_or_default();
assert_eq!(first, "craft-fonts BIZ UDPGothic Regular", "{stack:?}");
}
#[test]
fn the_ui_works_without_the_craft_fonts() {
// Built either way, the fonts install and Japanese text lays out (as installed-font
// fallbacks or missing-glyph boxes when no font has it).
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut fonts = UiFonts::default();
frame(&ctx, &mut fonts, "日本語の文字 Layer 1");
fonts.finish(&ctx);
frame(&ctx, &mut fonts, "日本語の文字 Layer 1");
if vectorcraft_text::CRAFT_FONTS.is_empty() {
let names: Vec<String> = ctx.fonts(|f| f.definitions().font_data.keys().cloned().collect());
assert!(names.iter().all(|n| !n.starts_with("craft-fonts")), "{names:?}");
}
assert!(has_glyph(&ctx, 'L'));
}
#[test]
fn cjk_names_get_an_installed_fallback_font() {
let text = "Logo 标志 ロゴ 로고";
+34
View File
@@ -24,6 +24,40 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
- **No control server:** browsers can't listen on TCP. To automate the web build, drive headless Chrome with `--remote-debugging-port`.
- Quick smoke test: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --enable-unsafe-webgpu --screenshot=web.png --window-size=1440,900 --virtual-time-budget=15000 http://127.0.0.1:8766/` (headless Chrome on macOS gets a real WebGPU adapter).
## Fonts: craft-fonts (optional build input)
Font files are never committed to this repository. Fonts shared by the Crafting Apps live in
[storytold/craft-fonts](https://github.com/storytold/craft-fonts); the rules are in craftrules
[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). To add
a font, add it there. (The Latin fonts in `assets/fonts/` predate the rule and stay.)
VectorCraft builds, tests and runs without craft-fonts. To embed its Japanese fonts (BIZ UDPGothic
for UI text, Shippori Mincho and BIZ UDMincho for document text), point the `CRAFT_FONTS_DIR` build
option at a checkout:
```sh
git clone https://github.com/storytold/craft-fonts ../craft-fonts
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci # also runs the Japanese-glyph tests
```
- `crates/text/build.rs` reads the checkout's `fonts/manifest.txt` and embeds every font it lists as
`vectorcraft_text::CRAFT_FONTS` (empty without `CRAFT_FONTS_DIR`). Give it an absolute path:
build scripts run in the crate's directory (a relative path is taken from the workspace root
as a convenience, but CI and the docs always use absolute paths). Nothing is downloaded, and
craft-fonts is never a `Cargo.toml` dependency. A bad path is a build warning, or an error with
`CRAFT_FONTS_REQUIRED=1` (release builds).
- Web (wasm32) builds embed only BIZ UDPGothic Regular (+4.7 MB of `.wasm`), to stay well under
static hosts' per-file limits (Cloudflare Pages: 25 MiB). Shippori Mincho, which this repo used to
embed everywhere, was 8.7 MB, so the web build is still smaller than before.
- Document text: the Japanese faces join the font database after the bundled fonts (Mincho first),
so they are the fallback for Japanese after the requested font; installed system fonts come after.
- UI: `theme::install_fonts` adds them at the end of every egui family (BIZ UDPGothic first), after
the UI fonts and before the installed fonts `ui_fonts.rs` discovers for other scripts.
- Tests that need Japanese glyphs skip with a message when built without craft-fonts. The FreeBSD
CI job and every release job (`release.yml`) check craft-fonts out at a pinned commit; release
packages carry each embedded font's `OFL-<family>.txt`.
## Vendor names gate
`cargo xtask brands` (part of `cargo xtask ci`) fails when user-visible text names another vendor's products or company: string literals in Rust sources (command labels and params docs, menus, panels, MCP tool definitions), `Cargo.toml` descriptions and packaging files. Comments and test code are not checked. Say "the reference app" or name the feature itself. A line that must keep an old name, such as an alias that files or preferences from earlier versions still use, carries a `brand-ok` comment.
+1 -1
View File
@@ -67,7 +67,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
| `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). |
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group. |
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). |
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
+13
View File
@@ -50,6 +50,19 @@ copy_docs() {
for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do
if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi
done
copy_font_licences "$dest"
}
# Built with craft-fonts (CRAFT_FONTS_DIR, set by release.yml): the embedded fonts' licences go with
# the package, as OFL-<family dir>.txt. Nothing to do without it.
copy_font_licences() {
local dest="$1" ofl family
[ -n "${CRAFT_FONTS_DIR:-}" ] || return 0
for ofl in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do
[ -f "$ofl" ] || continue
family="$(basename "$(dirname "$ofl")")"
cp "$ofl" "$dest/OFL-$family.txt"
done
}
# Portable SHA-256 of a file (prints just the hash).
+2
View File
@@ -108,6 +108,8 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \
"$HERE/Info.plist.in" >"$APP/Contents/Info.plist"
plutil -lint "$APP/Contents/Info.plist"
printf 'APPL????' >"$APP/Contents/PkgInfo"
# The licences of the craft-fonts fonts embedded in the binary (release builds).
copy_font_licences "$APP/Contents/Resources"
# Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature).
# Today the only nested code is the main executable; frameworks/helpers would be signed here too.
+34 -3
View File
@@ -17,9 +17,10 @@
.EXAMPLE
pwsh packaging/windows/package.ps1 -Arch x64
pwsh packaging/windows/package.ps1 -Arch x86 -SkipBuild
pwsh packaging/windows/package.ps1 -Arch arm64 # cross-compiled; needs the MSVC ARM64 build tools
#>
param(
[ValidateSet('x64', 'x86')] [string] $Arch = 'x64',
[ValidateSet('x64', 'x86', 'arm64')] [string] $Arch = 'x64',
[switch] $SkipBuild
)
$ErrorActionPreference = 'Stop'
@@ -44,7 +45,7 @@ if (-not $Version) { throw 'could not read [workspace.package] version from Carg
# MSI ProductVersion is numeric (major.minor.build); pre-release tags are dropped there.
$MsiVersion = ($Version -split '-')[0]
$Target = if ($Arch -eq 'x64') { 'x86_64-pc-windows-msvc' } else { 'i686-pc-windows-msvc' }
$Target = switch ($Arch) { 'x64' { 'x86_64-pc-windows-msvc' } 'x86' { 'i686-pc-windows-msvc' } 'arm64' { 'aarch64-pc-windows-msvc' } }
$Dist = if ($env:DIST) { $env:DIST } else { Join-Path $Root 'dist\release' }
$TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $Root 'target' }
New-Item -ItemType Directory -Force -Path $Dist | Out-Null
@@ -65,6 +66,23 @@ if (-not $SkipBuild) {
}
$Bin = Join-Path $TargetDir "$Target\release"
# Check both binaries' PE headers before packaging. Machine (COFF header) must match -Arch, so an
# x64 build can never ship labelled arm64. Subsystem (optional header): 2 = Windows GUI, 3 = console.
# The app must be GUI (no console window opens with it); the CLI must stay console so its output
# reaches the terminal.
function Get-PeHeader([string] $Path) {
$bytes = [System.IO.File]::ReadAllBytes($Path)
$pe = [BitConverter]::ToInt32($bytes, 0x3C)
return @{ Machine = [BitConverter]::ToUInt16($bytes, $pe + 4); Subsystem = [BitConverter]::ToUInt16($bytes, $pe + 0x5C) }
}
$Machine = switch ($Arch) { 'x64' { 0x8664 } 'x86' { 0x14C } 'arm64' { 0xAA64 } }
foreach ($check in @(@('vectorcraft.exe', 2), @('vectorcraft-cli.exe', 3))) {
$h = Get-PeHeader (Join-Path $Bin $check[0])
if ($h.Machine -ne $Machine) { throw "$($check[0]) is for machine 0x$('{0:X}' -f $h.Machine), expected 0x$('{0:X}' -f $Machine) ($Arch)" }
if ($h.Subsystem -ne $check[1]) { throw "$($check[0]) has PE subsystem $($h.Subsystem), expected $($check[1])" }
Write-Output "ok $($check[0]): $Arch, PE subsystem $($h.Subsystem)"
}
$Stage = Join-Path $TargetDir "windows-package\$Arch"
Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
@@ -92,9 +110,22 @@ foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') {
$p = Join-Path $Root $f
if (Test-Path $p) { Copy-Item $p $Portable }
}
# Built with craft-fonts (CRAFT_FONTS_DIR, release builds): the embedded fonts' licences.
if ($env:CRAFT_FONTS_DIR) {
foreach ($ofl in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -ErrorAction SilentlyContinue) {
Copy-Item $ofl.FullName (Join-Path $Portable "OFL-$($ofl.Directory.Name).txt")
}
}
$Zip = Join-Path $Dist "vectorcraft-$Version-windows-$Arch-portable.zip"
Remove-Item -Force $Zip -ErrorAction SilentlyContinue
Compress-Archive -Path $Portable -DestinationPath $Zip
Invoke-Native 'vectorcraft-cli --version' { & (Join-Path $Stage 'vectorcraft-cli.exe') --version }
# Smoke-test the CLI when this machine can run it. An ARM64 build made on an x64 runner can't run
# here; .github/workflows/windows-arm64.yml installs and runs it on ARM64 instead.
$HostArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()
if ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') {
Invoke-Native 'vectorcraft-cli --version' { & (Join-Path $Stage 'vectorcraft-cli.exe') --version }
} else {
Write-Output "skipping vectorcraft-cli --version: an $Arch build doesn't run on this $HostArch machine"
}
Get-Item $Msi, $Zip | Format-Table Name, Length
+3 -3
View File
@@ -23,8 +23,8 @@
DowngradeErrorMessage="A newer version of VectorCraft is already installed." />
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<Icon Id="VectorcraftIcon" SourceFile="$(var.IconPath)" />
<Property Id="ARPPRODUCTICON" Value="VectorcraftIcon" />
<Icon Id="VectorcraftIcon.ico" SourceFile="$(var.IconPath)" />
<Property Id="ARPPRODUCTICON" Value="VectorcraftIcon.ico" />
<Property Id="ARPURLINFOABOUT" Value="https://getartcraft.com/apps/vectorcraft" />
<Property Id="ARPURLUPDATEINFO" Value="https://getartcraft.com/apps/vectorcraft" />
<Property Id="ARPHELPLINK" Value="https://discord.gg/artcraft" />
@@ -45,7 +45,7 @@
<File Id="VectorcraftExe" Source="$(var.BinDir)\vectorcraft.exe" KeyPath="yes">
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="VectorCraft"
Description="VectorCraft vector illustration app" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
Icon="VectorcraftIcon" IconIndex="0" />
Icon="VectorcraftIcon.ico" IconIndex="0" />
</File>
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\vectorcraft.exe" Value="[#VectorcraftExe]" Type="string" />