collective

Browse / github.com/storytold/figcraft / patch

M4: draw dots for zero-length round dashes

M4: draw dots for zero-length round dashes Extracts the pathops change requested on #1143. Zero-length dashes with round caps now draw dots. The existing dash-expansion guard runs after the adjustment, retaining its solid fallback for excessively dense patterns. Synthetic regression tests cover visible dots, unchanged butt-cap behavior and the dense-pattern fallback. No Penpot or document-model changes. Validation: full cargo xtask ci passed on Windows (formatting, clippy, workspace tests, layers, attribution, localization and WASM). Uses the CI-pinned external craft-fonts input at 43913056; no font files are added. <!-- This

Status
open
Score
0.00
Adopters
0 other operators
Author
Guillergood (imported from pull request #1439, open)
Base
bdb7e846060964510dcef1e49d235d0c74dda30b
Size
+49 −2 in 3 files
Review
pending
Shared
2026-10-28
Id
0a691750fb4c385db8590a11

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/pathops/src/dot_dash_tests.rs crates/pathops/src/lib.rs

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -2,7 +2,7 @@  **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)  FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through@@ -132,6 +132,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).  ## Progress log +- **2026-10-27** · M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged.+ - **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged. - **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. - **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.@@ -348,6 +350,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged. | | 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged | | 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | | 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |diff --git a/crates/pathops/src/dot_dash_tests.rs b/crates/pathops/src/dot_dash_tests.rsnew file mode 100644--- /dev/null+++ b/crates/pathops/src/dot_dash_tests.rs@@ -0,0 +1,40 @@+use super::*;++#[test]+fn dense_round_dashes_fall_back_before_expansion() {+    let mut line = BezPath::new();+    line.move_to((0.0, 0.0));+    line.line_to((10_000.0, 0.0));+    let dangerous = StrokeProps {+        weight: 2.0,+        dashes: vec![0.0, 0.02],+        cap_start: StrokeCap::Round,+        cap_end: StrokeCap::Round,+        align: StrokeAlign::Center,+        ..Default::default()+    };+    let outlined = stroke_center(&line, 2.0, &dangerous);+    let solid = stroke_center(&line, 2.0, &StrokeProps { dashes: vec![], ..dangerous });+    assert!((outlined.area().abs() - solid.area().abs()).abs() < 0.01);+    assert!(outlined.elements().len() < 1000, "fallback must not emit hundreds of thousands of dots");+}++#[test]+fn zero_length_round_dashes_draw_dots() {+    let mut line = BezPath::new();+    line.move_to((0.0, 0.0));+    line.line_to((100.0, 0.0));+    let round = StrokeProps {+        weight: 2.0,+        dashes: vec![0.0, 9.0],+        cap_start: StrokeCap::Round,+        cap_end: StrokeCap::Round,+        align: StrokeAlign::Center,+        ..Default::default()+    };+    let dots = stroke_center(&line, 2.0, &round);+    let area = dots.area().abs();+    assert!((area - 12.0 * std::f64::consts::PI).abs() < 1.5, "{area}");+    let butt = StrokeProps { cap_start: StrokeCap::None, cap_end: StrokeCap::None, ..round };+    assert!(stroke_center(&line, 2.0, &butt).area().abs() < 1e-6);+}diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs--- a/crates/pathops/src/lib.rs+++ b/crates/pathops/src/lib.rs@@ -10,6 +10,8 @@ pub mod brush; #[cfg(test)] mod brush_tests; #[cfg(test)]+mod dot_dash_tests;+#[cfg(test)] mod ops_tests; mod points; #[cfg(test)]@@ -309,7 +311,9 @@ pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath         .with_end_cap(kcap(props.cap_end));     // Each subpath restarts the pattern, so it can add up to one more pattern's worth of dashes.     let subpaths = path.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() as f64;-    if let Some(pat) = width::dash_pattern(props).filter(|p| width::dashes_fit(path.perimeter(1.0) + subpaths * p.iter().sum::<f64>(), p)) {+    let round = [props.cap_start, props.cap_end].into_iter().any(|c| matches!(kcap(c), Cap::Round));+    let pattern = width::dash_pattern(props).map(|p| p.into_iter().map(|d| if round && d == 0.0 { 1e-3 } else { d }).collect::<Vec<_>>());+    if let Some(pat) = pattern.filter(|p| width::dashes_fit(path.perimeter(1.0) + subpaths * p.iter().sum::<f64>(), p)) {         style = style.with_dashes(0.0, pat);     }     kurbo::stroke(path.iter(), &style, &kurbo::StrokeOpts::default(), 0.05)