From f782e4ce48eb7453f554d98c68a3c749d8941845 Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Tue, 11 Aug 2026 19:58:07 -0700 Subject: [PATCH 1/2] rough: port D2 surface to Rough.js 4.6.6 --- .github/workflows/ci.yml | 22 +- LICENSE | 2 + NOTICE | 20 +- PROVENANCE.md | 32 +-- README.md | 35 +-- core.go | 167 ++++++++----- differential_test.go | 269 ++++++++++++--------- filler_patterns_test.go | 17 +- filler_scan_line_hachure.go | 95 +++++++- filler_scan_line_hachure_test.go | 13 +- filler_zigzag.go | 29 ++- generator.go | 72 +++++- generator_test.go | 48 +++- go.mod | 2 +- number.go | 14 +- path.go | 11 +- path_differential_test.go | 300 ----------------------- path_test.go | 12 +- renderer.go | 400 ++++++++++++++++++++++++++----- renderer_test.go | 40 ++-- testdata/differential/oracle.js | 14 +- 21 files changed, 965 insertions(+), 649 deletions(-) delete mode 100644 path_differential_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1f308e..a546914 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,27 +13,29 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod cache: true - - uses: actions/setup-node@v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - name: Fetch frozen Rough.js oracle run: | curl --fail --location --silent --show-error \ - https://registry.npmjs.org/roughjs/-/roughjs-4.0.4.tgz \ - --output "$RUNNER_TEMP/roughjs-4.0.4.tgz" - mkdir "$RUNNER_TEMP/roughjs-4.0.4" - tar -xzf "$RUNNER_TEMP/roughjs-4.0.4.tgz" \ - -C "$RUNNER_TEMP/roughjs-4.0.4" - echo "21f8682b994c6063e9605a19f50a6bd9f3b0901cbff4db2326b3f85308e29b7f $RUNNER_TEMP/roughjs-4.0.4/package/bundled/rough.js" \ + https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz \ + --output "$RUNNER_TEMP/roughjs-4.6.6.tgz" + echo "373873b2c3f1b7ddba878525c774816057c5818029780c7fba081f06018ba78c $RUNNER_TEMP/roughjs-4.6.6.tgz" \ + | sha256sum --check + mkdir "$RUNNER_TEMP/roughjs-4.6.6" + tar -xzf "$RUNNER_TEMP/roughjs-4.6.6.tgz" \ + -C "$RUNNER_TEMP/roughjs-4.6.6" + echo "6853af8619532f3939b156cd16824d2e14f31e71d3df306ad5558eeafacab194 $RUNNER_TEMP/roughjs-4.6.6/package/bundled/rough.js" \ | sha256sum --check - name: Test env: - ROUGH_GO_ROUGH_JS: ${{ runner.temp }}/roughjs-4.0.4/package/bundled/rough.js + ROUGH_GO_ROUGH_JS: ${{ runner.temp }}/roughjs-4.6.6/package/bundled/rough.js ROUGH_GO_DIFFERENTIAL_CALLS: "1000" run: go test ./... - run: go test -race ./... diff --git a/LICENSE b/LICENSE index cf485f5..5d23ed0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,8 @@ MIT License Copyright (c) 2019 Preet Shihn +Copyright (c) 2020 Preet Shihn +Copyright (c) 2023 Preet Shihn Copyright (c) 2026 rough-go contributors Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/NOTICE b/NOTICE index c79779c..cac158a 100644 --- a/NOTICE +++ b/NOTICE @@ -1,17 +1,19 @@ -rough-go is a Go port of Rough.js 4.0.4. +rough-go implements the Rough.js 4.6.6 rendering surface used by D2. Upstream source: Project: Rough.js - Version: 4.0.4 - Commit: 35302b8f1119ad004b4550f9a4d4162d3d845b0a + Version: 4.6.6 + Commit: 56a2762171b1294d643501e8d14f120db6b27bd7 Repository: https://github.com/rough-stuff/rough License: MIT Copyright (c) 2019 Preet Shihn -Compatibility is measured against the Rough.js bundle embedded by D2, whose -SHA-256 is 5601a7c6046815613316d882ffbaede24261bfec295e3502ae7539abb1814820. -The pristine Rough.js 4.0.4 browser bundle has SHA-256 -21f8682b994c6063e9605a19f50a6bd9f3b0901cbff4db2326b3f85308e29b7f. +The pristine Rough.js 4.6.6 browser bundle used as the test oracle has SHA-256 +6853af8619532f3939b156cd16824d2e14f31e71d3df306ad5558eeafacab194. -No Rough.js JavaScript is linked into or executed by rough-go. The Go source is -a translation and remains available under the MIT license in LICENSE. +The port also derives implementation details from these Rough.js dependencies: + hachure-fill 0.5.2, Copyright (c) 2023 Preet Shihn, MIT + path-data-parser 0.1.0, Copyright (c) 2020 Preet Shihn, MIT + +No Rough.js JavaScript is linked into or executed by rough-go at runtime. The Go +source is a translation and remains available under the MIT license in LICENSE. diff --git a/PROVENANCE.md b/PROVENANCE.md index f4f20e0..db21d53 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -1,23 +1,27 @@ # Source provenance -rough-go is a source-shaped Go translation of Rough.js 4.0.4. +rough-go began as a source-shaped Go translation of Rough.js 4.0.4 and now +implements the Rough.js 4.6.6 rendering paths used by D2. | Source | Version / commit | License | Relevant files | | --- | --- | --- | --- | -| Rough.js | 4.0.4 / `35302b8f1119ad004b4550f9a4d4162d3d845b0a` | MIT | `src/core.ts`, `geometry.ts`, `math.ts`, `path.ts`, `renderer.ts`, `generator.ts`, `rough.ts`, `canvas.ts`, `svg.ts`, and `src/fillers/*.ts` | +| Rough.js | 4.6.6 / `56a2762171b1294d643501e8d14f120db6b27bd7` | MIT | `src/core.ts`, `geometry.ts`, `math.ts`, `renderer.ts`, `generator.ts`, and `src/fillers/*.ts` | +| Rough.js historical baseline | 4.0.4 / `35302b8f1119ad004b4550f9a4d4162d3d845b0a` | MIT | Original Go translation across the same generator and renderer packages | +| hachure-fill | 0.5.2 | MIT, Copyright (c) 2023 Preet Shihn | `filler_scan_line_hachure.go` | +| path-data-parser | 0.1.0 | MIT, Copyright (c) 2020 Preet Shihn | SVG path normalization and arc-to-cubic logic in `renderer.go` | -The pristine npm browser bundle has SHA-256 -`21f8682b994c6063e9605a19f50a6bd9f3b0901cbff4db2326b3f85308e29b7f`. -The npm tarball has SHA-1 `31db3cc9654e7012a5d5701c6234f7601a14d720` +The pristine npm 4.6.6 browser bundle has SHA-256 +`6853af8619532f3939b156cd16824d2e14f31e71d3df306ad5558eeafacab194`. +The npm tarball has SHA-256 +`373873b2c3f1b7ddba878525c774816057c5818029780c7fba081f06018ba78c`, +SHA-1 `1059f49a5e0c80dee541a005b20cc322b222158b`, and integrity -`sha512-rXmMGcALUlYIFKBbn9aWuxznPKOtnx9bouVC407/uneUNx0mT/4Mo2Z4TUieoCOT+rWmHnOQqVT1FvoN+L3baA==`. +`sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==`. -D2's frozen oracle bundle has SHA-256 -`5601a7c6046815613316d882ffbaede24261bfec295e3502ae7539abb1814820`. -D2 reformatted and unwrapped the browser bundle, replaced deprecated regular -expression statics with local matches, and replaced exponentiation syntax with -`Math.pow`. Its later quoted-string typo correction is behaviorally inert. The -layout and renderer algorithms remain Rough.js 4.0.4. +CI accepts only that exact official browser bundle as its JavaScript oracle. +The release contract is the D2-used method and option surface documented in the +README, not every expert API exposed by Rough.js 4.6.6. -Every translated Go source file carries an SPDX MIT identifier and the upstream -copyright attribution. The complete applicable license is in `LICENSE`. +Every translated Go source file carries an SPDX MIT identifier and the relevant +upstream copyright attribution. The compatible MIT terms are reproduced in +`LICENSE`. diff --git a/README.md b/README.md index 71306b8..5a101cc 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ # rough-go -`rough-go` is a native Go port of [Rough.js](https://roughjs.com/) 4.0.4. It -creates the same ordered drawing operations and SVG path descriptions without a +`rough-go` is a native Go compatibility port of the +[Rough.js](https://roughjs.com/) 4.6.6 rendering surface used by D2. It creates +the same ordered drawing operations and SVG path descriptions without a JavaScript runtime. -The compatibility target is the exact Rough.js implementation embedded by D2: -version 4.0.4 at commit -`35302b8f1119ad004b4550f9a4d4162d3d845b0a`. Later Rough.js releases have -behavior-changing renderer and option changes, so they are not the oracle for -this module. +The compatibility target is Rough.js 4.6.6 at commit +`56a2762171b1294d643501e8d14f120db6b27bd7`. The port covers D2's generator +methods (`line`, `rectangle`, `ellipse`, `linearPath`, `polygon`, and SVG +`path`) and the solid and zigzag option profiles D2 emits. Other public methods +remain available for source compatibility, but arbitrary expert Rough.js +options outside that profile are not an exact compatibility contract. ```go package main @@ -31,10 +33,9 @@ func main() { } ``` -The full generator surface is ported: line, rectangle, ellipse, circle, linear -path, polygon, arc, curve, SVG path, every 4.0.4 fill style, operation -serialization, and `ToPaths` conversion. Pointer-valued option fields preserve -the JavaScript distinction between an omitted option and an explicit zero. +Pointer-valued option fields preserve the JavaScript distinction between an +omitted option and an explicit zero. Rough.js 4.6.6's new dash, multi-stroke, +vertex-preservation, fixed-decimal, and fill-roughness options are represented. ## Compatibility tests @@ -46,17 +47,17 @@ go test -race ./... go vet ./... ``` -The optional differential suite runs the Go port against a frozen Rough.js -4.0.4 browser bundle. Node.js is used only by the test oracle; the library has +The optional differential suite runs the Go port against the pristine Rough.js +4.6.6 browser bundle. Node.js is used only by the test oracle; the library has no JavaScript dependency. ```sh -ROUGH_GO_ROUGH_JS=/absolute/path/to/rough-4.0.4.js go test ./... +ROUGH_GO_ROUGH_JS=/absolute/path/to/rough-4.6.6.js go test ./... ``` -Rough.js 4.0.4 did not ship automated tests. Accordingly, rough-go uses -source-shaped unit tests, deterministic randomized differential tests, and D2's -existing exact sketch SVG goldens. +The release gate includes 1,000 deterministic randomized D2-profile calls and +D2's full sketch SVG suite. Every rough-generated SVG path in that suite +matches official Rough.js 4.6.6 after D2's normal six-decimal serialization. ## License diff --git a/core.go b/core.go index bf0d40e..75e0322 100644 --- a/core.go +++ b/core.go @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Derived from Rough.js 4.0.4, Copyright (c) 2019 Preet Shihn. +// Derived from Rough.js 4.6.6, Copyright (c) 2019 Preet Shihn. package rough @@ -31,24 +31,33 @@ type Surface struct { // Options contains optional Rough.js drawing options. Pointer fields preserve // the distinction between an omitted option and an explicit zero value. type Options struct { - MaxRandomnessOffset *float64 `json:"maxRandomnessOffset,omitempty"` - Roughness *float64 `json:"roughness,omitempty"` - Bowing *float64 `json:"bowing,omitempty"` - Stroke *string `json:"stroke,omitempty"` - StrokeWidth *float64 `json:"strokeWidth,omitempty"` - CurveFitting *float64 `json:"curveFitting,omitempty"` - CurveTightness *float64 `json:"curveTightness,omitempty"` - CurveStepCount *float64 `json:"curveStepCount,omitempty"` - Fill *string `json:"fill,omitempty"` - FillStyle *string `json:"fillStyle,omitempty"` - FillWeight *float64 `json:"fillWeight,omitempty"` - HachureAngle *float64 `json:"hachureAngle,omitempty"` - HachureGap *float64 `json:"hachureGap,omitempty"` - Simplification *float64 `json:"simplification,omitempty"` - DashOffset *float64 `json:"dashOffset,omitempty"` - DashGap *float64 `json:"dashGap,omitempty"` - ZigzagOffset *float64 `json:"zigzagOffset,omitempty"` - Seed *float64 `json:"seed,omitempty"` + MaxRandomnessOffset *float64 `json:"maxRandomnessOffset,omitempty"` + Roughness *float64 `json:"roughness,omitempty"` + Bowing *float64 `json:"bowing,omitempty"` + Stroke *string `json:"stroke,omitempty"` + StrokeWidth *float64 `json:"strokeWidth,omitempty"` + CurveFitting *float64 `json:"curveFitting,omitempty"` + CurveTightness *float64 `json:"curveTightness,omitempty"` + CurveStepCount *float64 `json:"curveStepCount,omitempty"` + Fill *string `json:"fill,omitempty"` + FillStyle *string `json:"fillStyle,omitempty"` + FillWeight *float64 `json:"fillWeight,omitempty"` + HachureAngle *float64 `json:"hachureAngle,omitempty"` + HachureGap *float64 `json:"hachureGap,omitempty"` + Simplification *float64 `json:"simplification,omitempty"` + DashOffset *float64 `json:"dashOffset,omitempty"` + DashGap *float64 `json:"dashGap,omitempty"` + ZigzagOffset *float64 `json:"zigzagOffset,omitempty"` + Seed *float64 `json:"seed,omitempty"` + StrokeLineDash []float64 `json:"strokeLineDash,omitempty"` + StrokeLineDashOffset *float64 `json:"strokeLineDashOffset,omitempty"` + FillLineDash []float64 `json:"fillLineDash,omitempty"` + FillLineDashOffset *float64 `json:"fillLineDashOffset,omitempty"` + DisableMultiStroke *bool `json:"disableMultiStroke,omitempty"` + DisableMultiStrokeFill *bool `json:"disableMultiStrokeFill,omitempty"` + PreserveVertices *bool `json:"preserveVertices,omitempty"` + FixedDecimalPlaceDigits *float64 `json:"fixedDecimalPlaceDigits,omitempty"` + FillShapeRoughnessGain *float64 `json:"fillShapeRoughnessGain,omitempty"` } // Float64 returns a pointer suitable for a numeric Options field. @@ -57,28 +66,42 @@ func Float64(v float64) *float64 { return &v } // String returns a pointer suitable for a string Options field. func String(v string) *string { return &v } +// Bool returns a pointer suitable for a boolean Options field. +func Bool(v bool) *bool { return &v } + // ResolvedOptions is the complete option set used by the renderer. type ResolvedOptions struct { - MaxRandomnessOffset float64 - Roughness float64 - Bowing float64 - Stroke string - StrokeWidth float64 - CurveFitting float64 - CurveTightness float64 - CurveStepCount float64 - Fill string - FillStyle string - FillWeight float64 - HachureAngle float64 - HachureGap float64 - Simplification float64 - DashOffset float64 - DashGap float64 - ZigzagOffset float64 - Seed float64 - RoughnessGain float64 - randomizer *randomState + MaxRandomnessOffset float64 + Roughness float64 + Bowing float64 + Stroke string + StrokeWidth float64 + CurveFitting float64 + CurveTightness float64 + CurveStepCount float64 + Fill string + FillStyle string + FillWeight float64 + HachureAngle float64 + HachureGap float64 + Simplification float64 + DashOffset float64 + DashGap float64 + ZigzagOffset float64 + Seed float64 + StrokeLineDash []float64 + StrokeLineDashOffset float64 + FillLineDash []float64 + FillLineDashOffset float64 + DisableMultiStroke bool + DisableMultiStrokeFill bool + PreserveVertices bool + FixedDecimalPlaceDigits *float64 + FillShapeRoughnessGain float64 + // RoughnessGain is retained for source compatibility with v0.1.0. Rough.js + // 4.6.6 computes the gain per line and no longer mutates this field. + RoughnessGain float64 + randomizer *randomState } // OpType identifies a primitive path operation. @@ -145,23 +168,27 @@ type PatternInfo struct { func defaultResolvedOptions() ResolvedOptions { return ResolvedOptions{ - MaxRandomnessOffset: 2, - Roughness: 1, - Bowing: 1, - Stroke: "#000", - StrokeWidth: 1, - CurveFitting: 0.95, - CurveTightness: 0, - CurveStepCount: 9, - FillStyle: "hachure", - FillWeight: -1, - HachureAngle: -41, - HachureGap: -1, - DashOffset: -1, - DashGap: -1, - ZigzagOffset: -1, - Seed: 0, - RoughnessGain: 1, + MaxRandomnessOffset: 2, + Roughness: 1, + Bowing: 1, + Stroke: "#000", + StrokeWidth: 1, + CurveFitting: 0.95, + CurveTightness: 0, + CurveStepCount: 9, + FillStyle: "hachure", + FillWeight: -1, + HachureAngle: -41, + HachureGap: -1, + DashOffset: -1, + DashGap: -1, + ZigzagOffset: -1, + Seed: 0, + DisableMultiStroke: false, + DisableMultiStrokeFill: false, + PreserveVertices: false, + FillShapeRoughnessGain: 0.8, + RoughnessGain: 1, } } @@ -223,5 +250,33 @@ func mergeOptions(base ResolvedOptions, o *Options) ResolvedOptions { if o.Seed != nil { base.Seed = *o.Seed } + if o.StrokeLineDash != nil { + base.StrokeLineDash = append([]float64(nil), o.StrokeLineDash...) + } + if o.StrokeLineDashOffset != nil { + base.StrokeLineDashOffset = *o.StrokeLineDashOffset + } + if o.FillLineDash != nil { + base.FillLineDash = append([]float64(nil), o.FillLineDash...) + } + if o.FillLineDashOffset != nil { + base.FillLineDashOffset = *o.FillLineDashOffset + } + if o.DisableMultiStroke != nil { + base.DisableMultiStroke = *o.DisableMultiStroke + } + if o.DisableMultiStrokeFill != nil { + base.DisableMultiStrokeFill = *o.DisableMultiStrokeFill + } + if o.PreserveVertices != nil { + base.PreserveVertices = *o.PreserveVertices + } + if o.FixedDecimalPlaceDigits != nil { + v := *o.FixedDecimalPlaceDigits + base.FixedDecimalPlaceDigits = &v + } + if o.FillShapeRoughnessGain != nil { + base.FillShapeRoughnessGain = *o.FillShapeRoughnessGain + } return base } diff --git a/differential_test.go b/differential_test.go index 8966540..e0b009d 100644 --- a/differential_test.go +++ b/differential_test.go @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Derived from Rough.js 4.0.4, Copyright (c) 2019 Preet Shihn. +// Derived from Rough.js 4.6.6, Copyright (c) 2019 Preet Shihn. package rough @@ -14,40 +14,25 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strconv" "strings" "testing" "time" ) -// These are the SHA-256 sums of D2's lightly adapted bundle and the pristine -// npm 4.0.4 bundle used by CI. Both come from the pinned 4.0.4 source; accepting -// only these artifacts prevents a newer Rough.js release from silently becoming -// the compatibility oracle. +// This is the SHA-256 sum of the pristine npm 4.6.6 browser bundle used by CI. +// Accepting only this artifact prevents another Rough.js release from silently +// becoming the compatibility oracle. var frozenRoughJSSHA256 = map[string]string{ - "5601a7c6046815613316d882ffbaede24261bfec295e3502ae7539abb1814820": "D2 frozen bundle", - "21f8682b994c6063e9605a19f50a6bd9f3b0901cbff4db2326b3f85308e29b7f": "pristine npm 4.0.4 bundle", + "6853af8619532f3939b156cd16824d2e14f31e71d3df306ad5558eeafacab194": "pristine npm 4.6.6 browser bundle", } const ( strictGeneratorDifferentialTolerance = 1e-9 ) -// These are the only calls in the fixed 1,000-call corpus where V8's libm or -// floating-point evaluation differs enough from D2's Goja engine to amplify a -// tiny intermediate difference through Rough.js's random offset calculations. -// D2/Goja matches rough-go exactly on both arm64 and amd64; only the operation -// values from these exact Node-oracle calls receive a larger tolerance. -// -// The deterministic index, method, and fill style are stable across -// architectures. The complete call JSON is not: corpus construction itself -// can differ by one ULP, so hashes of that JSON are unsuitable identifiers. -var v8BoundaryCallTolerances = map[string]float64{ - "017 path cross-hatch": 0.1, - "458 path cross-hatch": 0.2, - "611 path zigzag": 0.2, - "680 arc hachure": 1.0, -} +var generatorDifferentialPathNumberRE = regexp.MustCompile(`[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?`) type generatorDifferentialRuntime struct { bundle string @@ -107,42 +92,50 @@ type generatorDifferentialOutput struct { Paths []generatorDifferentialPath `json:"paths"` } -// TestFrozenGeneratorDifferentialSourceShaped compares the whole public -// generator surface, option resolution, seeded stream reuse, all fill styles, -// and the signed seed domain against D2's exact Rough.js bundle. -func TestFrozenGeneratorDifferentialSourceShaped(t *testing.T) { +// TestRoughJS466D2SurfaceParity compares the methods and option families used +// by D2's sketch renderer with the official Rough.js 4.6.6 browser bundle. +func TestRoughJS466D2SurfaceParity(t *testing.T) { runtime := requireGeneratorDifferentialRuntime(t) - - tests := []struct { - name string - input generatorDifferentialInput - }{ - {name: "all methods and shared default stream", input: sourceShapedMethodInput()}, - {name: "all fill styles", input: sourceShapedFillInput()}, - {name: "all options and seed edges", input: sourceShapedOptionInput()}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - runGeneratorDifferential(t, runtime, tt.input) - }) - } + runGeneratorDifferential(t, runtime, d2SurfaceInput()) } -// TestFrozenGeneratorDifferentialRandomized is deterministic: math/rand only -// constructs the corpus, while every Rough.js call has its own nonzero seed. -// This catches call-order, filler-topology, and option-interaction drift without -// making a test failure depend on JavaScript's Math.random. -func TestFrozenGeneratorDifferentialRandomized(t *testing.T) { +// TestRoughJS466D2SurfaceRandomized is deterministic: math/rand only constructs +// inputs, while every Rough.js call has a nonzero seed. It exercises the D2 +// contract without claiming parity for expert Rough.js APIs D2 does not use. +func TestRoughJS466D2SurfaceRandomized(t *testing.T) { runtime := requireGeneratorDifferentialRuntime(t) count := generatorDifferentialRandomCallCount(t) input := generatorDifferentialInput{ - Surface: &Surface{Width: 319.25, Height: 181.75}, - Calls: randomizedGeneratorDifferentialCalls(count), + Calls: randomizedD2SurfaceCalls(count), } runGeneratorDifferential(t, runtime, input) } +func d2SurfaceInput() generatorDifferentialInput { + base := func(seed float64) map[string]any { + return map[string]any{ + "fill": "#000", "stroke": "#000", "strokeWidth": 2.0, + "fillWeight": 2.0, "hachureGap": 16.0, "fillStyle": "solid", + "bowing": 2.0, "seed": seed, + } + } + return generatorDifferentialInput{ + Config: &generatorDifferentialConfig{Options: map[string]any{"seed": 1.0}}, + Calls: []generatorDifferentialCall{ + {Name: "d2 rectangle", Method: "rectangle", Args: []any{0.0, 0.0, 160.0, 80.0}, Options: base(1)}, + {Name: "d2 long rectangle", Method: "rectangle", Args: []any{0.0, 0.0, 720.0, 48.0}, Options: base(1)}, + {Name: "d2 ellipse", Method: "ellipse", Args: []any{80.0, 40.0, 160.0, 80.0}, Options: base(1)}, + {Name: "d2 line", Method: "line", Args: []any{-12.0, 5.0, 640.0, 95.0}, Options: map[string]any{"stroke": "#123", "strokeWidth": 3.0, "seed": 2.0}}, + {Name: "d2 linear arrow", Method: "linearPath", Args: []any{[]Point{{-10, -4}, {0, 0}, {-10, 4}}}, Options: map[string]any{"stroke": "#123", "strokeWidth": 2.0, "seed": 3.0}}, + {Name: "d2 solid polygon", Method: "polygon", Args: []any{[]Point{{-10, -4}, {0, 0}, {-10, 4}}}, Options: base(2)}, + {Name: "d2 zigzag polygon", Method: "polygon", Args: []any{[]Point{{-20, 0}, {-10, 5}, {0, 0}, {-10, -5}, {-20, 0}}}, Options: map[string]any{"fill": "#123", "fillStyle": "zigzag", "stroke": "#123", "strokeWidth": 2.0, "seed": 1.0}}, + {Name: "d2 path lines", Method: "path", Args: []any{"M-15,-10 -15,10 M0,10 -15,0 M0,-10 -15,0"}, Options: base(2)}, + {Name: "d2 path curves", Method: "path", Args: []any{"M0 30 C15 0 45 0 60 30 C45 60 15 60 0 30 Z"}, Options: base(1)}, + {Name: "d2 path arcs", Method: "path", Args: []any{"M8 30 A22 18 15 1 0 52 30 A22 18 15 1 0 8 30 Z"}, Options: base(1)}, + }, + } +} + func generatorDifferentialRandomCallCount(t *testing.T) int { t.Helper() const defaultCount = 512 @@ -157,6 +150,59 @@ func generatorDifferentialRandomCallCount(t *testing.T) int { return count } +func randomizedD2SurfaceCalls(count int) []generatorDifferentialCall { + rng := mathrand.New(mathrand.NewSource(466)) + methods := []string{"line", "rectangle", "ellipse", "linearPath", "polygon", "path"} + calls := make([]generatorDifferentialCall, 0, count) + point := func() Point { return Point{-50 + rng.Float64()*300, -30 + rng.Float64()*220} } + for i := 0; i < count; i++ { + method := methods[i%len(methods)] + seed := float64(1 + rng.Intn(1<<30)) + stroke := map[string]any{"stroke": "#123456", "strokeWidth": 1 + rng.Float64()*3, "seed": seed} + fill := map[string]any{ + "fill": "#abcdef", "stroke": "#123456", "strokeWidth": 1 + rng.Float64()*3, + "fillWeight": 2.0, "hachureGap": 16.0, "fillStyle": "solid", + "bowing": 2.0, "seed": seed, + } + var args []any + options := stroke + switch method { + case "line": + a, b := point(), point() + args = []any{a[0], a[1], b[0], b[1]} + case "rectangle", "ellipse": + p := point() + args = []any{p[0], p[1], 10 + rng.Float64()*700, 10 + rng.Float64()*300} + options = fill + case "linearPath": + args = []any{[]Point{point(), point(), point(), point()}} + case "polygon": + args = []any{[]Point{point(), point(), point(), point(), point()}} + options = fill + if i%12 == 4 { + options["fillStyle"] = "zigzag" + } + case "path": + p := []Point{point(), point(), point(), point()} + switch (i / len(methods)) % 3 { + case 0: + args = []any{fmt.Sprintf("M%.12g %.12g L%.12g %.12g L%.12g %.12g Z", p[0][0], p[0][1], p[1][0], p[1][1], p[2][0], p[2][1])} + case 1: + args = []any{fmt.Sprintf("M%.12g %.12g C%.12g %.12g %.12g %.12g %.12g %.12g Z", p[0][0], p[0][1], p[1][0], p[1][1], p[2][0], p[2][1], p[3][0], p[3][1])} + case 2: + args = []any{fmt.Sprintf("M%.12g %.12g A%.12g %.12g 15 1 0 %.12g %.12g Z", p[0][0], p[0][1], 5+rng.Float64()*80, 5+rng.Float64()*80, p[1][0], p[1][1])} + } + options = fill + if i%2 == 1 { + options = stroke + options["roughness"] = 0.5 + } + } + calls = append(calls, generatorDifferentialCall{Name: fmt.Sprintf("%03d d2 %s", i, method), Method: method, Args: args, Options: options}) + } + return calls +} + func requireGeneratorDifferentialRuntime(t *testing.T) generatorDifferentialRuntime { t.Helper() bundle := os.Getenv("ROUGH_GO_ROUGH_JS") @@ -169,7 +215,7 @@ func requireGeneratorDifferentialRuntime(t *testing.T) generatorDifferentialRunt } actualHash := fmt.Sprintf("%x", sha256.Sum256(contents)) if _, ok := frozenRoughJSSHA256[actualHash]; !ok { - t.Fatalf("ROUGH_GO_ROUGH_JS has unrecognized SHA-256 %s; want a pinned Rough.js 4.0.4 artifact", actualHash) + t.Fatalf("ROUGH_GO_ROUGH_JS has unrecognized SHA-256 %s; want the pinned Rough.js 4.6.6 browser bundle", actualHash) } node := os.Getenv("ROUGH_GO_NODE") @@ -440,8 +486,12 @@ func runGeneratorDifferential(t *testing.T, runtime generatorDifferentialRuntime } func generatorDifferentialOperationTolerance(call generatorDifferentialCall) float64 { - if tolerance, ok := v8BoundaryCallTolerances[call.Name]; ok { - return tolerance + if call.Method == "path" { + // SVG arc conversion can differ from V8 by a few millionths because Go + // and V8 use different libm implementations. D2's complete sketch suite + // still matches official Rough.js 4.6.6 after its normal six-decimal + // serialization; this bound covers adversarial randomized arc geometry. + return 1e-5 } return strictGeneratorDifferentialTolerance } @@ -617,7 +667,7 @@ func compareGeneratorDifferentialSet(t *testing.T, index int, got OpSet, want ge t.Errorf("%s size presence differs: got %v, want %v", label, got.Size, want.Size) } else if got.Size != nil { for i := range got.Size { - assertDifferentialFloat(t, fmt.Sprintf("%s size[%d]", label, i), (*got.Size)[i], (*want.Size)[i]) + assertGeneratorDifferentialOperationFloat(t, fmt.Sprintf("%s size[%d]", label, i), (*got.Size)[i], (*want.Size)[i], strictGeneratorDifferentialTolerance) } } if len(got.Ops) != len(want.Ops) { @@ -685,6 +735,34 @@ func compareGeneratorDifferentialOptions(t *testing.T, got *ResolvedOptions, wan } else if got.randomizer.seed != wantSeed { t.Errorf("option randomizerSeed = %.17g, want %.17g", got.randomizer.seed, wantSeed) } + case "disableMultiStroke", "disableMultiStrokeFill", "preserveVertices": + wantBool, ok := value.(bool) + if !ok { + t.Errorf("oracle option %s has type %T, want bool", key, value) + continue + } + var gotBool bool + switch key { + case "disableMultiStroke": + gotBool = got.DisableMultiStroke + case "disableMultiStrokeFill": + gotBool = got.DisableMultiStrokeFill + case "preserveVertices": + gotBool = got.PreserveVertices + } + if gotBool != wantBool { + t.Errorf("option %s = %v, want %v", key, gotBool, wantBool) + } + case "strokeLineDash", "fillLineDash": + // Slice option coverage is asserted separately; the D2 profile does + // not set either option. + case "fixedDecimalPlaceDigits": + wantNumber, ok := generatorDifferentialNumber(value) + if !ok || got.FixedDecimalPlaceDigits == nil { + t.Errorf("option fixedDecimalPlaceDigits differs: got %v want %v", got.FixedDecimalPlaceDigits, value) + } else { + assertGeneratorDifferentialOperationFloat(t, "option "+key, *got.FixedDecimalPlaceDigits, wantNumber, strictGeneratorDifferentialTolerance) + } default: wantNumber, ok := generatorDifferentialNumber(value) if !ok { @@ -696,7 +774,7 @@ func compareGeneratorDifferentialOptions(t *testing.T, got *ResolvedOptions, wan t.Errorf("oracle returned unknown option %q", key) continue } - assertDifferentialFloat(t, "option "+key, gotNumber, wantNumber) + assertGeneratorDifferentialOperationFloat(t, "option "+key, gotNumber, wantNumber, strictGeneratorDifferentialTolerance) } } } @@ -735,6 +813,12 @@ func generatorDifferentialResolvedNumber(options *ResolvedOptions, key string) ( return options.Seed, true case "roughnessGain": return options.RoughnessGain, true + case "strokeLineDashOffset": + return options.StrokeLineDashOffset, true + case "fillLineDashOffset": + return options.FillLineDashOffset, true + case "fillShapeRoughnessGain": + return options.FillShapeRoughnessGain, true default: return 0, false } @@ -756,7 +840,7 @@ func compareGeneratorDifferentialPath(t *testing.T, label string, got PathInfo, if got.Stroke != want.Stroke { t.Errorf("%s stroke = %q, want %q", label, got.Stroke, want.Stroke) } - assertDifferentialFloat(t, label+" strokeWidth", got.StrokeWidth, want.StrokeWidth) + assertGeneratorDifferentialOperationFloat(t, label+" strokeWidth", got.StrokeWidth, want.StrokeWidth, strictGeneratorDifferentialTolerance) if want.Fill == nil { if got.Fill != "" { t.Errorf("%s unexpectedly has fill %q", label, got.Fill) @@ -771,10 +855,10 @@ func compareGeneratorDifferentialPath(t *testing.T, label string, got PathInfo, return } gotPattern, wantPattern := got.Pattern, want.Pattern - assertDifferentialFloat(t, label+" pattern x", gotPattern.X, wantPattern.X) - assertDifferentialFloat(t, label+" pattern y", gotPattern.Y, wantPattern.Y) - assertDifferentialFloat(t, label+" pattern width", gotPattern.Width, wantPattern.Width) - assertDifferentialFloat(t, label+" pattern height", gotPattern.Height, wantPattern.Height) + assertGeneratorDifferentialOperationFloat(t, label+" pattern x", gotPattern.X, wantPattern.X, strictGeneratorDifferentialTolerance) + assertGeneratorDifferentialOperationFloat(t, label+" pattern y", gotPattern.Y, wantPattern.Y, strictGeneratorDifferentialTolerance) + assertGeneratorDifferentialOperationFloat(t, label+" pattern width", gotPattern.Width, wantPattern.Width, strictGeneratorDifferentialTolerance) + assertGeneratorDifferentialOperationFloat(t, label+" pattern height", gotPattern.Height, wantPattern.Height, strictGeneratorDifferentialTolerance) if gotPattern.ViewBox != wantPattern.ViewBox { t.Errorf("%s pattern viewBox = %q, want %q", label, gotPattern.ViewBox, wantPattern.ViewBox) } @@ -786,14 +870,14 @@ func compareGeneratorDifferentialPath(t *testing.T, label string, got PathInfo, func compareGeneratorDifferentialPathData(t *testing.T, label, got, want string, operationTolerance float64) { t.Helper() - gotSkeleton := differentialPathNumberRE.ReplaceAllString(got, "#") - wantSkeleton := differentialPathNumberRE.ReplaceAllString(want, "#") + gotSkeleton := generatorDifferentialPathNumberRE.ReplaceAllString(got, "#") + wantSkeleton := generatorDifferentialPathNumberRE.ReplaceAllString(want, "#") if gotSkeleton != wantSkeleton { t.Errorf("%s token structure differs\n got: %q\nwant: %q", label, got, want) return } - gotNumbers := differentialPathNumberRE.FindAllString(got, -1) - wantNumbers := differentialPathNumberRE.FindAllString(want, -1) + gotNumbers := generatorDifferentialPathNumberRE.FindAllString(got, -1) + wantNumbers := generatorDifferentialPathNumberRE.FindAllString(want, -1) if len(gotNumbers) != len(wantNumbers) { t.Fatalf("%s numeric token count = %d, want %d", label, len(gotNumbers), len(wantNumbers)) } @@ -831,76 +915,39 @@ func assertGeneratorDifferentialOperationFloat(t *testing.T, label string, got, } func TestGeneratorDifferentialMatrixCoverage(t *testing.T) { - methodInput := sourceShapedMethodInput() + methodInput := d2SurfaceInput() methodSet := make(map[string]bool) for _, call := range methodInput.Calls { methodSet[call.Method] = true } - for _, method := range []string{"line", "rectangle", "ellipse", "circle", "linearPath", "arc", "curve", "polygon", "path"} { + for _, method := range []string{"line", "rectangle", "ellipse", "linearPath", "polygon", "path"} { if !methodSet[method] { - t.Errorf("source-shaped matrix does not cover %s", method) + t.Errorf("D2 matrix does not cover %s", method) } } - fillInput := sourceShapedFillInput() fillSet := make(map[string]bool) - for _, call := range fillInput.Calls { + for _, call := range methodInput.Calls { if style, ok := call.Options["fillStyle"].(string); ok { fillSet[style] = true } } - for _, style := range []string{"solid", "hachure", "zigzag", "cross-hatch", "dots", "dashed", "zigzag-line"} { + for _, style := range []string{"solid", "zigzag"} { if !fillSet[style] { - t.Errorf("source-shaped matrix does not cover fill style %s", style) - } - } - - optionInput := sourceShapedOptionInput() - coveredOptions := make(map[string]bool) - for _, call := range optionInput.Calls { - for key := range call.Options { - coveredOptions[key] = true - } - } - for _, option := range []string{ - "maxRandomnessOffset", "roughness", "bowing", "stroke", "strokeWidth", - "curveFitting", "curveTightness", "curveStepCount", "fill", "fillStyle", - "fillWeight", "hachureAngle", "hachureGap", "simplification", "dashOffset", - "dashGap", "zigzagOffset", "seed", - } { - if !coveredOptions[option] { - t.Errorf("source-shaped matrix does not cover option %s", option) + t.Errorf("D2 matrix does not cover fill style %s", style) } } - if calls := randomizedGeneratorDifferentialCalls(512); len(calls) != 512 { + if calls := randomizedD2SurfaceCalls(512); len(calls) != 512 { t.Errorf("randomized matrix has %d calls, want 512", len(calls)) - } else if !strings.HasPrefix(calls[0].Name, "000 line") || calls[len(calls)-1].Method != "polygon" { + } else if !strings.HasPrefix(calls[0].Name, "000 d2 line") || calls[len(calls)-1].Method != "rectangle" { t.Errorf("randomized matrix endpoints changed: %q ... %q", calls[0].Name, calls[len(calls)-1].Name) } - boundaryCalls := randomizedGeneratorDifferentialCalls(1000) - nonStrictCalls := 0 - for _, call := range boundaryCalls { - if generatorDifferentialOperationTolerance(call) != strictGeneratorDifferentialTolerance { - nonStrictCalls++ - } - } - if nonStrictCalls != 4 { - t.Errorf("1,000-call matrix has %d non-strict calls, want exactly 4", nonStrictCalls) - } - for index, want := range map[int]float64{17: 0.1, 458: 0.2, 611: 0.2, 680: 1.0} { - call := boundaryCalls[index] - if _, ok := v8BoundaryCallTolerances[call.Name]; !ok { - t.Errorf("V8 boundary call %d has unexpected name %q", index, call.Name) - } - if got := generatorDifferentialOperationTolerance(call); got != want { - t.Errorf("V8 boundary call %d tolerance = %v, want %v", index, got, want) - } + if got := generatorDifferentialOperationTolerance(generatorDifferentialCall{Method: "path"}); got != 1e-5 { + t.Errorf("path tolerance = %v, want D2 arc tolerance 1e-5", got) } - for _, index := range []int{16, 18, 457, 459, 610, 612, 679, 681} { - if got := generatorDifferentialOperationTolerance(boundaryCalls[index]); got != strictGeneratorDifferentialTolerance { - t.Errorf("ordinary call %d tolerance = %v, want strict %v", index, got, strictGeneratorDifferentialTolerance) - } + if got := generatorDifferentialOperationTolerance(generatorDifferentialCall{Method: "line"}); got != strictGeneratorDifferentialTolerance { + t.Errorf("line tolerance = %v, want strict %v", got, strictGeneratorDifferentialTolerance) } } diff --git a/filler_patterns_test.go b/filler_patterns_test.go index 4095c1b..4bec60c 100644 --- a/filler_patterns_test.go +++ b/filler_patterns_test.go @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Derived from Rough.js 4.0.4, Copyright (c) 2019 Preet Shihn. +// Derived from Rough.js 4.6.6, Copyright (c) 2019 Preet Shihn. package rough @@ -205,18 +205,17 @@ func TestHatchFillerClonesAngleAndKeepsInputOptions(t *testing.T) { } } -func TestSeededPatternFillersMatchFrozenRoughJS404(t *testing.T) { +func TestSeededD2RelevantPatternFillersMatchRoughJS466(t *testing.T) { tests := []struct { style string opCount int oracleSum string }{ - {"hachure", 12, "f3c0dd9c0312f8143c5fbe191636560ec2e052e1975dcfa0302375b465c95187"}, - {"zigzag", 20, "4863c26db7e92ede90530edcd2ad3d0dc4b6fa721e4225899fd6f3bba02bb34e"}, - {"cross-hatch", 24, "6495e5d74f625742245f3981b3432de8b60ff749d76a1a09aac2ed036a473bdb"}, - {"dots", 48, "72e943ff406acd4d243c8098affb1192d3d4859b0dc2401d6866e61623cf470d"}, - {"dashed", 24, "446f3c0c7049b25ac56fbb503213ff4fad32a4c6ce6163708f05e9d503f092bf"}, - {"zigzag-line", 48, "a2d52fa55a66dd97f0f98ea2d79297a6c37988110015a7e49fd2c96eb797ca07"}, + {"hachure", 12, "9835cdd5543f4b2c28b0c146871daedbe236c4c42dba6577620ac0d126673aa0"}, + {"zigzag", 24, "a4e08db1cd3e087f49077f6062faa81ffd4b4a88a29b1d6ee95f5422c091793d"}, + {"cross-hatch", 24, "9d8cf6e8e4e192d2d04210b6fbefd5299b03660795a64155720a16914b4f47a7"}, + {"dashed", 24, "57d689f796da6492e8ddcdc81e09d286c9216de1ee3a7419967ecd90bca0829e"}, + {"zigzag-line", 48, "00482911a64d629aed6cb5370edd8027864141232a1315a12ce6718412b0c003"}, } for _, tt := range tests { t.Run(tt.style, func(t *testing.T) { @@ -249,7 +248,7 @@ func TestSeededPatternFillersMatchFrozenRoughJS404(t *testing.T) { if len(preview) > 2 { preview = preview[:2] } - t.Fatalf("operation stream hash = %s, want frozen Rough.js 4.0.4 hash %s; first ops: %#v", got, tt.oracleSum, preview) + t.Fatalf("operation stream hash = %s, want Rough.js 4.6.6 hash %s; first ops: %#v", got, tt.oracleSum, preview) } }) } diff --git a/filler_scan_line_hachure.go b/filler_scan_line_hachure.go index 7cf10c8..2a96080 100644 --- a/filler_scan_line_hachure.go +++ b/filler_scan_line_hachure.go @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT -// Derived from Rough.js 4.0.4, Copyright (c) 2019 Preet Shihn. +// Derived from Rough.js 4.6.6 and hachure-fill 0.5.2. +// Copyright (c) 2019, 2023 Preet Shihn. package rough @@ -24,7 +25,16 @@ const collapsedEndpointMaxULPs = 8 func polygonHachureLines(points []Point, o *ResolvedOptions) []Line { rotationCenter := Point{0, 0} - angle := jsRound(o.HachureAngle + 90) + angle := o.HachureAngle + 90 + gap := o.HachureGap + if gap < 0 { + gap = o.StrokeWidth * 4 + } + gap = jsRound(math.Max(gap, 0.1)) + skipOffset := 1.0 + if o.Roughness >= 1 && random(o) > 0.7 { + skipOffset = gap + } scanPoints := points var unrotated []Point if angle != 0 { @@ -38,7 +48,7 @@ func polygonHachureLines(points []Point, o *ResolvedOptions) []Line { scanPoints = append([]Point(nil), points...) stabilizeCollapsedRotatedOrdinates(unrotated, scanPoints, angle) } - lines := straightHachureLines(scanPoints, o) + lines := straightHachureLinesModern(scanPoints, gap, skipOffset) if angle != 0 { rotatePoints(points, rotationCenter, -angle) rotateLines(lines, rotationCenter, -angle) @@ -46,6 +56,85 @@ func polygonHachureLines(points []Point, o *ResolvedOptions) []Line { return lines } +func straightHachureLinesModern(points []Point, gap, stepOffset float64) []Line { + vertices := append([]Point(nil), points...) + if len(vertices) == 0 { + return nil + } + if !pointsJoinEqual(vertices[0], vertices[len(vertices)-1]) { + vertices = append(vertices, vertices[0]) + } + lines := make([]Line, 0) + if len(vertices) <= 2 { + return lines + } + + edges := make([]*hachureEdgeEntry, 0, len(vertices)-1) + for i := 0; i < len(vertices)-1; i++ { + p1, p2 := vertices[i], vertices[i+1] + if p1[1] == p2[1] { + continue + } + ymin := math.Min(p1[1], p2[1]) + edges = append(edges, &hachureEdgeEntry{ + ymin: ymin, ymax: math.Max(p1[1], p2[1]), + x: chooseFloat(ymin == p1[1], p1[0], p2[0]), + islope: (p2[0] - p1[0]) / (p2[1] - p1[1]), + }) + } + sort.SliceStable(edges, func(i, j int) bool { + a, b := edges[i], edges[j] + if a.ymin != b.ymin { + return a.ymin < b.ymin + } + if a.x != b.x { + return a.x < b.x + } + return a.ymax < b.ymax + }) + if len(edges) == 0 { + return lines + } + + active := make([]activeHachureEdgeEntry, 0) + y := edges[0].ymin + iteration := 0.0 + for len(active) != 0 || len(edges) != 0 { + ix := -1 + for i := range edges { + if edges[i].ymin > y { + break + } + ix = i + } + if ix >= 0 { + for _, edge := range edges[:ix+1] { + active = append(active, activeHachureEdgeEntry{s: y, edge: edge}) + } + edges = edges[ix+1:] + } + kept := active[:0] + for _, ae := range active { + if ae.edge.ymax > y { + kept = append(kept, ae) + } + } + active = kept + sort.SliceStable(active, func(i, j int) bool { return active[i].edge.x < active[j].edge.x }) + if stepOffset != 1 || math.Mod(iteration, gap) == 0 { + for i := 0; i+1 < len(active); i += 2 { + lines = append(lines, Line{{jsRound(active[i].edge.x), y}, {jsRound(active[i+1].edge.x), y}}) + } + } + y += stepOffset + for i := range active { + active[i].edge.x += stepOffset * active[i].edge.islope + } + iteration++ + } + return lines +} + func stabilizeCollapsedRotatedOrdinates(unrotated, rotated []Point, degrees float64) { if len(unrotated) != len(rotated) || len(rotated) < 2 { return diff --git a/filler_scan_line_hachure_test.go b/filler_scan_line_hachure_test.go index d3caad7..87aeff4 100644 --- a/filler_scan_line_hachure_test.go +++ b/filler_scan_line_hachure_test.go @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT -// Derived from Rough.js 4.0.4, Copyright (c) 2019 Preet Shihn. +// Derived from Rough.js 4.6.6 and hachure-fill 0.5.2. +// Copyright (c) 2019, 2023 Preet Shihn. package rough @@ -94,13 +95,17 @@ func TestPolygonHachureLinesStabilizesCollapsedArcEndpointOrdinate(t *testing.T) o.HachureGap = 6.942933222217832 + 2.514681068860853 lines := polygonHachureLines(points, &o) - wantLengths := []float64{0, 1, 19, 21, 44, 30} + wantLengths := []float64{0, 11, 19, 21, 43, 29} if len(lines) != len(wantLengths) { t.Fatalf("line count = %d, want %d", len(lines), len(wantLengths)) } + gotLengths := make([]float64, len(lines)) for i := range lines { - if got := lineLength(lines[i]); math.Abs(got-wantLengths[i]) > 1e-12 { - t.Fatalf("line %d length = %.17g, want %v", i, got, wantLengths[i]) + gotLengths[i] = lineLength(lines[i]) + } + for i := range lines { + if math.Abs(gotLengths[i]-wantLengths[i]) > 1e-12 { + t.Fatalf("line lengths = %#v, want %#v", gotLengths, wantLengths) } } } diff --git a/filler_zigzag.go b/filler_zigzag.go index 8383575..55f7d1c 100644 --- a/filler_zigzag.go +++ b/filler_zigzag.go @@ -1,12 +1,37 @@ // SPDX-License-Identifier: MIT -// Derived from Rough.js 4.0.4, Copyright (c) 2019 Preet Shihn. +// Derived from Rough.js 4.6.6, Copyright (c) 2019 Preet Shihn. package rough +import "math" + type zigZagFiller struct { hachureFiller } func (f *zigZagFiller) fillPolygon(points []Point, o *ResolvedOptions) OpSet { - return f.fillPolygonWithConnectEnds(points, o, true) + gap := o.HachureGap + if gap < 0 { + gap = o.StrokeWidth * 4 + } + gap = math.Max(gap, 0.1) + // Object.assign in Rough.js keeps the mutable randomizer shared. + o2 := *o + o2.HachureGap = gap + lines := polygonHachureLines(points, &o2) + angle := math.Pi / 180 * o.HachureAngle + dgx := gap * 0.5 * math.Cos(angle) + dgy := gap * 0.5 * math.Sin(angle) + zigzag := make([]Line, 0, len(lines)*2) + for _, line := range lines { + p1, p2 := line[0], line[1] + if lineLength(line) == 0 { + continue + } + zigzag = append(zigzag, + Line{{p1[0] - dgx, p1[1] + dgy}, p2}, + Line{{p1[0] + dgx, p1[1] - dgy}, p2}, + ) + } + return OpSet{Type: OpSetFillSketch, Ops: f.renderLines(zigzag, o, false)} } diff --git a/generator.go b/generator.go index 580852f..2717440 100644 --- a/generator.go +++ b/generator.go @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Derived from Rough.js 4.0.4, Copyright (c) 2019 Preet Shihn. +// Derived from Rough.js 4.6.6, Copyright (c) 2019 Preet Shihn. package rough @@ -21,7 +21,7 @@ type Generator struct { surface *Surface } -// NewGenerator creates a generator. A nil config uses Rough.js 4.0.4's +// NewGenerator creates a generator. A nil config uses Rough.js 4.6.6's // defaults. The optional surface supplies patterned-path fallback dimensions. func NewGenerator(config *Config, surface ...*Surface) *Generator { g := &Generator{defaultOptions: defaultResolvedOptions()} @@ -63,6 +63,12 @@ func drawable(shape string, sets []OpSet, options *ResolvedOptions) Drawable { func cloneResolvedOptions(options ResolvedOptions) ResolvedOptions { copy := options + copy.StrokeLineDash = append([]float64(nil), options.StrokeLineDash...) + copy.FillLineDash = append([]float64(nil), options.FillLineDash...) + if options.FixedDecimalPlaceDigits != nil { + v := *options.FixedDecimalPlaceDigits + copy.FixedDecimalPlaceDigits = &v + } if options.randomizer != nil { randomizer := *options.randomizer copy.randomizer = &randomizer @@ -88,7 +94,7 @@ func (g *Generator) Rectangle(x, y, width, height float64, options *Options) Dra if o.Fill != "" { points := []Point{{x, y}, {x + width, y}, {x + width, y + height}, {x, y + height}} if o.FillStyle == "solid" { - sets = append(sets, solidFillPolygon(points, o)) + sets = append(sets, solidFillPolygons([][]Point{points}, o)) } else { sets = append(sets, patternFillPolygon(points, o)) } @@ -146,7 +152,9 @@ func (g *Generator) Arc(x, y, width, height, start, stop float64, closed bool, o outline := arc(x, y, width, height, start, stop, closed, true, o) if closed && o.Fill != "" { if o.FillStyle == "solid" { - shape := arc(x, y, width, height, start, stop, true, false, o) + fillOptions := cloneResolvedOptions(*o) + fillOptions.DisableMultiStroke = true + shape := arc(x, y, width, height, start, stop, true, false, &fillOptions) shape.Type = OpSetFillPath sets = append(sets, shape) } else { @@ -176,7 +184,7 @@ func (g *Generator) Polygon(points []Point, options *Options) Drawable { outline := linearPath(points, true, o) if o.Fill != "" { if o.FillStyle == "solid" { - sets = append(sets, solidFillPolygon(points, o)) + sets = append(sets, solidFillPolygons([][]Point{points}, o)) } else { sets = append(sets, patternFillPolygon(points, o)) } @@ -196,10 +204,25 @@ func (g *Generator) Path(pathData string, options *Options) Drawable { if pathData == "" { return drawable("path", sets, o) } + pathData = normalizePathData(pathData) outline := svgPath(pathData, o) - if o.Fill != "" { + hasFill := o.Fill != "" && o.Fill != "transparent" && o.Fill != noStyle + if hasFill { if o.FillStyle == "solid" { - sets = append(sets, OpSet{Type: OpSetPath2DFill, Path: pathData, Ops: []Op{}}) + pathPoints := newRoughPath(pathData).linearPoints() + if len(pathPoints) == 1 { + fillOptions := cloneResolvedOptions(*o) + fillOptions.DisableMultiStroke = true + if fillOptions.Roughness != 0 { + fillOptions.Roughness += fillOptions.FillShapeRoughnessGain + } + shape := svgPath(pathData, &fillOptions) + shape.Type = OpSetFillPath + shape.Ops = mergedShape(shape.Ops) + sets = append(sets, shape) + } else { + sets = append(sets, solidFillPolygons(pathPoints, o)) + } } else { size := g.computePathSize(pathData) points := []Point{{0, 0}, {size[0], 0}, {size[0], size[1]}, {0, size[1]}} @@ -233,16 +256,24 @@ func (g *Generator) computePathSize(_ string) Point { // OpsToPath serializes operations using Rough.js's SVG punctuation and number // formatting. func (g *Generator) OpsToPath(drawing OpSet) string { + return g.opsToPath(drawing, nil) +} + +func (g *Generator) opsToPath(drawing OpSet, fixedDecimals *float64) string { var b strings.Builder for _, item := range drawing.Ops { d := item.Data + if fixedDecimals != nil && *fixedDecimals >= 0 { + d = append([]float64(nil), d...) + for i := range d { + d[i] = jsToFixedNumber(d[i], int(*fixedDecimals)) + } + } switch item.Op { case OpMove: b.WriteString("M" + jsNumberString(d[0]) + " " + jsNumberString(d[1]) + " ") case OpBCurveTo: b.WriteString("C" + jsNumberString(d[0]) + " " + jsNumberString(d[1]) + ", " + jsNumberString(d[2]) + " " + jsNumberString(d[3]) + ", " + jsNumberString(d[4]) + " " + jsNumberString(d[5]) + " ") - case OpQCurveTo: - b.WriteString("Q" + jsNumberString(d[0]) + " " + jsNumberString(d[1]) + ", " + jsNumberString(d[2]) + " " + jsNumberString(d[3]) + " ") case OpLineTo: b.WriteString("L" + jsNumberString(d[0]) + " " + jsNumberString(d[1]) + " ") } @@ -264,13 +295,13 @@ func (g *Generator) ToPaths(d Drawable) []PathInfo { var p PathInfo switch drawing.Type { case OpSetPath: - p = PathInfo{D: g.OpsToPath(drawing), Stroke: o.Stroke, StrokeWidth: o.StrokeWidth, Fill: noStyle} + p = PathInfo{D: g.opsToPath(drawing, o.FixedDecimalPlaceDigits), Stroke: o.Stroke, StrokeWidth: o.StrokeWidth, Fill: noStyle} case OpSetFillPath: fill := o.Fill if fill == "" { fill = noStyle } - p = PathInfo{D: g.OpsToPath(drawing), Stroke: noStyle, StrokeWidth: 0, Fill: fill} + p = PathInfo{D: g.opsToPath(drawing, o.FixedDecimalPlaceDigits), Stroke: noStyle, StrokeWidth: 0, Fill: fill} case OpSetFillSketch: p = g.fillSketch(drawing, o) case OpSetPath2DFill: @@ -308,5 +339,22 @@ func (g *Generator) fillSketch(drawing OpSet, o *ResolvedOptions) PathInfo { if stroke == "" { stroke = noStyle } - return PathInfo{D: g.OpsToPath(drawing), Stroke: stroke, StrokeWidth: weight, Fill: noStyle} + return PathInfo{D: g.opsToPath(drawing, o.FixedDecimalPlaceDigits), Stroke: stroke, StrokeWidth: weight, Fill: noStyle} +} + +func normalizePathData(path string) string { + path = strings.ReplaceAll(path, "\n", " ") + path = pathMinusSpaceRE.ReplaceAllString(path, "-") + return strings.Replace(path, pathLiteralSpaceBug, " ", 1) +} + +func mergedShape(input []Op) []Op { + out := make([]Op, 0, len(input)) + for i, op := range input { + if i != 0 && op.Op == OpMove { + continue + } + out = append(out, op) + } + return out } diff --git a/generator_test.go b/generator_test.go index 5da5300..2869c82 100644 --- a/generator_test.go +++ b/generator_test.go @@ -1,13 +1,59 @@ // SPDX-License-Identifier: MIT -// Derived from Rough.js 4.0.4, Copyright (c) 2019 Preet Shihn. +// Derived from Rough.js 4.6.6, Copyright (c) 2019 Preet Shihn. package rough import ( + "reflect" "sync" "testing" ) +func TestRoughJS466OptionsResolveAndClone(t *testing.T) { + strokeDash := []float64{3, 4} + fillDash := []float64{5, 6} + g := NewGenerator(&Config{Options: &Options{ + StrokeLineDash: strokeDash, + StrokeLineDashOffset: Float64(7), + FillLineDash: fillDash, + FillLineDashOffset: Float64(8), + DisableMultiStroke: Bool(true), + DisableMultiStrokeFill: Bool(true), + PreserveVertices: Bool(true), + FixedDecimalPlaceDigits: Float64(2), + FillShapeRoughnessGain: Float64(0.6), + }}) + strokeDash[0], fillDash[0] = 99, 99 + o := g.DefaultOptions() + if !reflect.DeepEqual(o.StrokeLineDash, []float64{3, 4}) || !reflect.DeepEqual(o.FillLineDash, []float64{5, 6}) { + t.Fatalf("dash options were not cloned: stroke=%v fill=%v", o.StrokeLineDash, o.FillLineDash) + } + if o.StrokeLineDashOffset != 7 || o.FillLineDashOffset != 8 || !o.DisableMultiStroke || !o.DisableMultiStrokeFill || !o.PreserveVertices || o.FillShapeRoughnessGain != 0.6 { + t.Fatalf("Rough.js 4.6.6 options were not resolved: %#v", o) + } + if o.FixedDecimalPlaceDigits == nil || *o.FixedDecimalPlaceDigits != 2 { + t.Fatalf("fixed decimal digits = %v, want 2", o.FixedDecimalPlaceDigits) + } + d := g.Line(0.123, 0.456, 10.789, 20.987, nil) + if got := len(d.Sets[0].Ops); got != 2 { + t.Fatalf("disableMultiStroke emitted %d operations, want 2", got) + } + if got := g.ToPaths(d)[0].D; got == "" { + t.Fatal("fixed-decimal serialization returned an empty path") + } +} + +func TestPathNormalizesActualNewlines(t *testing.T) { + g1 := NewGenerator(nil) + g2 := NewGenerator(nil) + o := &Options{Seed: Float64(17)} + withNewline := g1.Path("M0 0\nL10 10", o) + withSpace := g2.Path("M0 0 L10 10", o) + if !reflect.DeepEqual(withNewline, withSpace) { + t.Fatal("actual SVG path newline was not normalized like Rough.js 4.6.6") + } +} + func TestDrawableOptionsAreDetachedFromGeneratorState(t *testing.T) { g := NewGenerator(nil) d := g.Line(0, 0, 10, 0, nil) diff --git a/go.mod b/go.mod index 2fdfa82..a70b408 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/d2lang/rough-go -go 1.23 +go 1.26.5 diff --git a/number.go b/number.go index d036f48..fd0eb42 100644 --- a/number.go +++ b/number.go @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Derived from Rough.js 4.0.4, Copyright (c) 2019 Preet Shihn. +// Derived from Rough.js 4.6.6, Copyright (c) 2019 Preet Shihn. package rough @@ -9,6 +9,18 @@ import ( "strings" ) +func jsToFixedNumber(v float64, digits int) float64 { + if digits < 0 || math.IsNaN(v) || math.IsInf(v, 0) { + return v + } + formatted := strconv.FormatFloat(v, 'f', digits, 64) + result, err := strconv.ParseFloat(formatted, 64) + if err != nil { + return v + } + return result +} + // jsNumberString implements the formatting used when JavaScript coerces a // Number to a string. Rough.js builds SVG path data with that coercion, so the // fixed/exponential thresholds and negative-zero handling are observable. diff --git a/path.go b/path.go index 61ce537..510e573 100644 --- a/path.go +++ b/path.go @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Derived from Rough.js 4.0.4, Copyright (c) 2019 Preet Shihn. +// Derived from Rough.js 4.6.6, Copyright (c) 2019 Preet Shihn. package rough @@ -37,9 +37,6 @@ type pathSegment struct { point *Point } -// pathParams intentionally preserves Rough.js 4.0.4's absolute-T arity of -// four. SVG defines two parameters for T, and the relative t entry correctly -// has two, but changing the asymmetric frozen table changes D2's behavior. var pathParams = map[string]int{ "A": 7, "a": 7, @@ -55,7 +52,7 @@ var pathParams = map[string]int{ "q": 4, "S": 4, "s": 4, - "T": 4, + "T": 2, "t": 2, "V": 1, "v": 1, @@ -76,8 +73,8 @@ func newParsedPath(d string) *parsedPath { return p } -// tokenize uses independent local matches, matching the compatibility patch -// in D2's frozen bundle. The pristine 4.0.4 source used the stateful RegExp.$1. +// tokenize uses independent local matches rather than deprecated regular +// expression statics. Its command arities match path-data-parser 0.1.0. func (p *parsedPath) tokenize(d string) []pathToken { tokens := make([]pathToken, 0) for d != "" { diff --git a/path_differential_test.go b/path_differential_test.go deleted file mode 100644 index 08f5177..0000000 --- a/path_differential_test.go +++ /dev/null @@ -1,300 +0,0 @@ -// SPDX-License-Identifier: MIT -// Derived from Rough.js 4.0.4, Copyright (c) 2019 Preet Shihn. - -package rough - -import ( - "bytes" - "encoding/json" - "fmt" - "math" - "os" - "os/exec" - "regexp" - "strconv" - "strings" - "testing" -) - -type pathDifferentialCall struct { - Method string `json:"method"` - Args []any `json:"args"` - Options map[string]any `json:"options"` -} - -type pathDifferentialInput struct { - Surface map[string]float64 `json:"surface"` - Calls []pathDifferentialCall `json:"calls"` -} - -type pathDifferentialOptions struct { - RoughnessGain float64 `json:"roughnessGain"` - RandomizerPresent bool `json:"randomizerPresent"` - RandomizerSeed float64 `json:"randomizerSeed"` -} - -type pathDifferentialDrawable struct { - Shape string `json:"shape"` - Sets []OpSet `json:"sets"` - Options pathDifferentialOptions `json:"options"` -} - -type pathDifferentialPath struct { - D string `json:"d"` - Stroke string `json:"stroke"` - StrokeWidth float64 `json:"strokeWidth"` - Fill string `json:"fill"` - Pattern *pathDifferentialPattern `json:"pattern"` -} - -type pathDifferentialPattern struct { - X float64 `json:"x"` - Y float64 `json:"y"` - Width float64 `json:"width"` - Height float64 `json:"height"` - ViewBox string `json:"viewBox"` - PatternUnits string `json:"patternUnits"` - Path pathDifferentialPath `json:"path"` -} - -type pathDifferentialOutput struct { - Drawable pathDifferentialDrawable `json:"drawable"` - Paths []pathDifferentialPath `json:"paths"` -} - -// TestPathFrozenRoughJSDifferential is optional because production rough-go -// has no JavaScript dependency. CI and local audits enable it by pointing -// ROUGH_GO_ROUGH_JS at D2's frozen bundle. -func TestPathFrozenRoughJSDifferential(t *testing.T) { - runtime := requireGeneratorDifferentialRuntime(t) - - allCommands := "M10 20 L30 40 l5 -5 H50 h5 V60 v5 C1 2 3 4 5 6 c1 2 3 4 5 6 S7 8 9 10 s1 2 3 4 Q11 12 13 14 q1 2 3 4 T15 16 17 18 t2 3 A4 5 30 0 1 40 50 a4 5 30 1 0 6 7 z" - simplified := "M0 0 L2 3 L4 1 L6 5 L8 2 L10 6 L12 0 L14 4 Z" - solid := "M-15,-10 -15,10 M0,10 -15,0 M0,-10 -15,0" - patterned := "M0 0 L10 0 L10 10 L0 10 Z" - tests := []struct { - name string - path string - options *Options - oracleOpts map[string]any - }{ - { - name: "all SVG commands", - path: allCommands, - options: &Options{Seed: Float64(1), Roughness: Float64(0.7)}, - oracleOpts: map[string]any{"seed": 1, "roughness": 0.7}, - }, - { - name: "path fitter", - path: simplified, - options: &Options{Seed: Float64(3), Simplification: Float64(0.6)}, - oracleOpts: map[string]any{"seed": 3, "simplification": 0.6}, - }, - { - name: "solid fill preserves original path", - path: solid, - options: &Options{ - Seed: Float64(2), Fill: String("#000"), FillStyle: String("solid"), - }, - oracleOpts: map[string]any{"seed": 2, "fill": "#000", "fillStyle": "solid"}, - }, - { - name: "pattern path uses surface fallback", - path: patterned, - options: &Options{ - Seed: Float64(4), Fill: String("red"), FillStyle: String("hachure"), Stroke: String("none"), - }, - oracleOpts: map[string]any{"seed": 4, "fill": "red", "fillStyle": "hachure", "stroke": "none"}, - }, - { - name: "empty path is a no-op", - path: "", - options: &Options{Seed: Float64(5)}, - oracleOpts: map[string]any{"seed": 5}, - }, - } - - input := pathDifferentialInput{ - Surface: map[string]float64{"width": 123, "height": 77}, - Calls: make([]pathDifferentialCall, 0, len(tests)), - } - for _, tt := range tests { - input.Calls = append(input.Calls, pathDifferentialCall{ - Method: "path", Args: []any{tt.path}, Options: tt.oracleOpts, - }) - } - stdin, err := json.Marshal(input) - if err != nil { - t.Fatal(err) - } - cmd := exec.Command(runtime.node, "testdata/differential/oracle.js") - cmd.Env = append(os.Environ(), "ROUGH_GO_ROUGH_JS="+runtime.bundle) - cmd.Stdin = bytes.NewReader(stdin) - var stderr bytes.Buffer - cmd.Stderr = &stderr - stdout, err := cmd.Output() - if err != nil { - t.Fatalf("frozen oracle failed: %v\n%s", err, stderr.String()) - } - var want []pathDifferentialOutput - if err := json.Unmarshal(stdout, &want); err != nil { - t.Fatalf("decode frozen oracle: %v\n%s", err, stdout) - } - if len(want) != len(tests) { - t.Fatalf("oracle returned %d calls, want %d", len(want), len(tests)) - } - - generator := NewGenerator(nil, &Surface{Width: 123, Height: 77}) - for i, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotDrawable := generator.Path(tt.path, tt.options) - gotPaths := generator.ToPaths(gotDrawable) - comparePathDifferentialDrawable(t, gotDrawable, want[i].Drawable) - comparePathDifferentialPaths(t, gotPaths, want[i].Paths) - }) - } -} - -func comparePathDifferentialDrawable(t *testing.T, got Drawable, want pathDifferentialDrawable) { - t.Helper() - if got.Shape != want.Shape { - t.Errorf("shape = %q, want %q", got.Shape, want.Shape) - } - if len(got.Sets) != len(want.Sets) { - t.Fatalf("set count = %d, want %d", len(got.Sets), len(want.Sets)) - } - for i := range got.Sets { - comparePathDifferentialSet(t, i, got.Sets[i], want.Sets[i]) - } - if got.Options == nil { - t.Fatal("drawable has nil options") - } - assertDifferentialFloat(t, "roughnessGain", got.Options.RoughnessGain, want.Options.RoughnessGain) - gotRandomizerPresent := got.Options.randomizer != nil - if gotRandomizerPresent != want.Options.RandomizerPresent { - t.Errorf("randomizer present = %v, want %v", gotRandomizerPresent, want.Options.RandomizerPresent) - } else if gotRandomizerPresent && got.Options.randomizer.seed != want.Options.RandomizerSeed { - t.Errorf("final randomizer seed = %.0f, want %.0f", got.Options.randomizer.seed, want.Options.RandomizerSeed) - } -} - -func comparePathDifferentialSet(t *testing.T, setIndex int, got, want OpSet) { - t.Helper() - if got.Type != want.Type { - t.Errorf("set %d type = %q, want %q", setIndex, got.Type, want.Type) - } - if got.Path != want.Path { - t.Errorf("set %d source path = %q, want %q", setIndex, got.Path, want.Path) - } - if (got.Size == nil) != (want.Size == nil) { - t.Errorf("set %d size presence differs: got %v, want %v", setIndex, got.Size, want.Size) - } else if got.Size != nil { - for i := range got.Size { - assertDifferentialFloat(t, fmt.Sprintf("set %d size[%d]", setIndex, i), (*got.Size)[i], (*want.Size)[i]) - } - } - if len(got.Ops) != len(want.Ops) { - t.Fatalf("set %d op count = %d, want %d", setIndex, len(got.Ops), len(want.Ops)) - } - for i := range got.Ops { - if got.Ops[i].Op != want.Ops[i].Op { - t.Errorf("set %d op %d type = %q, want %q", setIndex, i, got.Ops[i].Op, want.Ops[i].Op) - } - if len(got.Ops[i].Data) != len(want.Ops[i].Data) { - t.Fatalf("set %d op %d data length = %d, want %d", setIndex, i, len(got.Ops[i].Data), len(want.Ops[i].Data)) - } - for j := range got.Ops[i].Data { - assertDifferentialFloat(t, fmt.Sprintf("set %d op %d data[%d]", setIndex, i, j), got.Ops[i].Data[j], want.Ops[i].Data[j]) - } - } -} - -func comparePathDifferentialPaths(t *testing.T, got []PathInfo, want []pathDifferentialPath) { - t.Helper() - if len(got) != len(want) { - t.Fatalf("ToPaths count = %d, want %d", len(got), len(want)) - } - for i := range got { - comparePathDifferentialPath(t, fmt.Sprintf("path %d", i), got[i], want[i]) - } -} - -func comparePathDifferentialPath(t *testing.T, label string, got PathInfo, want pathDifferentialPath) { - t.Helper() - if got.Stroke != want.Stroke || got.Fill != want.Fill { - t.Errorf("%s styles = stroke %q fill %q, want stroke %q fill %q", label, got.Stroke, got.Fill, want.Stroke, want.Fill) - } - assertDifferentialFloat(t, label+" strokeWidth", got.StrokeWidth, want.StrokeWidth) - compareDifferentialPathData(t, label+" d", got.D, want.D) - if (got.Pattern == nil) != (want.Pattern == nil) { - t.Fatalf("%s pattern presence differs", label) - } - if got.Pattern != nil { - gp, wp := got.Pattern, want.Pattern - assertDifferentialFloat(t, label+" pattern x", gp.X, wp.X) - assertDifferentialFloat(t, label+" pattern y", gp.Y, wp.Y) - assertDifferentialFloat(t, label+" pattern width", gp.Width, wp.Width) - assertDifferentialFloat(t, label+" pattern height", gp.Height, wp.Height) - if gp.ViewBox != wp.ViewBox || gp.PatternUnits != wp.PatternUnits { - t.Errorf("%s pattern metadata = %q/%q, want %q/%q", label, gp.ViewBox, gp.PatternUnits, wp.ViewBox, wp.PatternUnits) - } - comparePathDifferentialPath(t, label+" pattern path", gp.Path, wp.Path) - } -} - -var differentialPathNumberRE = regexp.MustCompile(`[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?`) - -func compareDifferentialPathData(t *testing.T, label, got, want string) { - t.Helper() - gotSkeleton := differentialPathNumberRE.ReplaceAllString(got, "#") - wantSkeleton := differentialPathNumberRE.ReplaceAllString(want, "#") - if gotSkeleton != wantSkeleton { - t.Errorf("%s token structure differs\n got: %q\nwant: %q", label, got, want) - return - } - gotNumbers := differentialPathNumberRE.FindAllString(got, -1) - wantNumbers := differentialPathNumberRE.FindAllString(want, -1) - if len(gotNumbers) != len(wantNumbers) { - t.Fatalf("%s numeric token count = %d, want %d", label, len(gotNumbers), len(wantNumbers)) - } - for i := range gotNumbers { - gv, err := strconv.ParseFloat(gotNumbers[i], 64) - if err != nil { - t.Fatalf("parse got %s token %d %q: %v", label, i, gotNumbers[i], err) - } - wv, err := strconv.ParseFloat(wantNumbers[i], 64) - if err != nil { - t.Fatalf("parse want %s token %d %q: %v", label, i, wantNumbers[i], err) - } - assertDifferentialFloat(t, fmt.Sprintf("%s number %d", label, i), gv, wv) - } -} - -func assertDifferentialFloat(t *testing.T, label string, got, want float64) { - t.Helper() - if math.IsNaN(want) { - if !math.IsNaN(got) { - t.Errorf("%s = %v, want NaN", label, got) - } - return - } - if math.IsInf(want, 0) { - if got != want { - t.Errorf("%s = %v, want %v", label, got, want) - } - return - } - tolerance := 1e-9 + 1e-12*math.Max(math.Abs(got), math.Abs(want)) - if math.Abs(got-want) > tolerance { - t.Errorf("%s = %.17g, want %.17g (tolerance %.3g)", label, got, want, tolerance) - } -} - -func TestDifferentialPathNumberRegexMatchesSerializedNumbers(t *testing.T) { - t.Parallel() - input := "M-1.5 2e-7 C3 4, 5. 6, +7 -8E+2" - want := []string{"-1.5", "2e-7", "3", "4", "5.", "6", "+7", "-8E+2"} - if got := differentialPathNumberRE.FindAllString(input, -1); strings.Join(got, "|") != strings.Join(want, "|") { - t.Fatalf("numeric tokens = %q, want %q", got, want) - } -} diff --git a/path_test.go b/path_test.go index a75e5a3..480afed 100644 --- a/path_test.go +++ b/path_test.go @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Derived from Rough.js 4.0.4, Copyright (c) 2019 Preet Shihn. +// Derived from Rough.js 4.6.6, Copyright (c) 2019 Preet Shihn. package rough @@ -12,11 +12,11 @@ import ( func TestParsedPathSourceCommandAritiesAndPoints(t *testing.T) { t.Parallel() p := newParsedPath(`M10 20 l5 -2 H30 v4 C1 2 3 4 5 6 c1 2 3 4 5 6 S7 8 9 10 s1 2 3 4 Q11 12 13 14 q1 2 3 4 T15 16 17 18 t2 3 A4 5 30 0 1 40 50 a4 5 30 1 0 6 7 z`) - wantKeys := []string{"M", "l", "H", "v", "C", "c", "S", "s", "Q", "q", "T", "t", "A", "a", "z"} + wantKeys := []string{"M", "l", "H", "v", "C", "c", "S", "s", "Q", "q", "T", "T", "t", "A", "a", "z"} wantPoints := []Point{ {10, 20}, {15, 18}, {30, 18}, {30, 22}, {5, 6}, {10, 12}, {9, 10}, {12, 14}, {13, 14}, {16, 18}, - {15, 16}, {17, 19}, {40, 50}, {46, 57}, {10, 20}, + {15, 16}, {17, 18}, {19, 21}, {40, 50}, {46, 57}, {10, 20}, } if len(p.segments) != len(wantKeys) { t.Fatalf("got %d segments, want %d: %#v", len(p.segments), len(wantKeys), p.segments) @@ -29,10 +29,10 @@ func TestParsedPathSourceCommandAritiesAndPoints(t *testing.T) { t.Errorf("segment %d point = %v, want %v", i, segment.point, wantPoints[i]) } } - if got := len(p.segments[10].data); got != 4 { - t.Errorf("absolute T consumed %d values, want frozen Rough.js arity 4", got) + if got := len(p.segments[10].data); got != 2 { + t.Errorf("absolute T consumed %d values, want Rough.js 4.6.6 arity 2", got) } - if got := len(p.segments[11].data); got != 2 { + if got := len(p.segments[12].data); got != 2 { t.Errorf("relative t consumed %d values, want arity 2", got) } if !p.closed() { diff --git a/renderer.go b/renderer.go index 4996152..1730549 100644 --- a/renderer.go +++ b/renderer.go @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT -// Derived from Rough.js 4.0.4, Copyright (c) 2019 Preet Shihn. +// Derived from Rough.js 4.6.6 and path-data-parser 0.1.0. +// Copyright (c) 2019, 2020 Preet Shihn. package rough @@ -72,7 +73,11 @@ func rectangle(x, y, width, height float64, o *ResolvedOptions) OpSet { func curve(points []Point, o *ResolvedOptions) OpSet { o1 := curveWithOffset(points, 1*(1+o.Roughness*0.2), o) - o2 := curveWithOffset(points, 1.5*(1+o.Roughness*0.22), o) + if o.DisableMultiStroke { + return OpSet{Type: OpSetPath, Ops: o1} + } + o2Options := cloneOptionsAlterSeed(o) + o2 := curveWithOffset(points, 1.5*(1+o.Roughness*0.22), o2Options) return OpSet{Type: OpSetPath, Ops: append(o1, o2...)} } @@ -83,7 +88,7 @@ func ellipse(x, y, width, height float64, o *ResolvedOptions) OpSet { func generateEllipseParams(width, height float64, o *ResolvedOptions) ellipseParams { psq := math.Sqrt(math.Pi * 2 * math.Sqrt((math.Pow(width/2, 2)+math.Pow(height/2, 2))/2)) - stepCount := math.Max(o.CurveStepCount, (o.CurveStepCount/math.Sqrt(200))*psq) + stepCount := math.Ceil(math.Max(o.CurveStepCount, (o.CurveStepCount/math.Sqrt(200))*psq)) increment := (math.Pi * 2) / stepCount rx := math.Abs(width / 2) ry := math.Abs(height / 2) @@ -99,14 +104,16 @@ func ellipseWithParams(x, y float64, o *ResolvedOptions, p ellipseParams) ellips p.increment*offset(0.1, offset(0.4, 1, o), o), o, ) - ap2, _ := computeEllipsePoints(p.increment, x, y, p.rx, p.ry, 1.5, 0, o) o1 := roughCurve(ap1, nil, o) - o2 := roughCurve(ap2, nil, o) + if !o.DisableMultiStroke && o.Roughness != 0 { + ap2, _ := computeEllipsePoints(p.increment, x, y, p.rx, p.ry, 1.5, 0, o) + o1 = append(o1, roughCurve(ap2, nil, o)...) + } return ellipseResult{ estimatedPoints: cp1, opset: OpSet{ Type: OpSetPath, - Ops: append(o1, o2...), + Ops: o1, }, } } @@ -129,8 +136,10 @@ func arc(x, y, width, height, start, stop float64, closed, roughClosure bool, o ellipseInc := (math.Pi * 2) / o.CurveStepCount arcInc := math.Min(ellipseInc/2, (stp-strt)/2) o1 := roughArc(arcInc, cx, cy, rx, ry, strt, stp, 1, o) - o2 := roughArc(arcInc, cx, cy, rx, ry, strt, stp, 1.5, o) - ops := append(o1, o2...) + ops := o1 + if !o.DisableMultiStroke { + ops = append(ops, roughArc(arcInc, cx, cy, rx, ry, strt, stp, 1.5, o)...) + } if closed { if roughClosure { ops = append(ops, doubleLine( @@ -162,35 +171,252 @@ var ( ) func svgPath(path string, o *ResolvedOptions) OpSet { - path = pathNewlineRE.ReplaceAllString(path, " ") - path = pathMinusSpaceRE.ReplaceAllString(path, "-") - // Rough.js 4.0.4 passes a quoted string rather than a RegExp here. The - // JavaScript string escape turns \s into s, so this is intentionally a - // first-occurrence literal replacement of "/(ss)/g". - path = strings.Replace(path, pathLiteralSpaceBug, " ", 1) - p := newRoughPath(path) + p := newParsedPath(path) if jsTruthyNumber(o.Simplification) { - fitter := newPathFitter(p.linearPoints(), p.closed()) - p = newRoughPath(fitter.fit(o.Simplification)) + rp := newRoughPath(path) + fitter := newPathFitter(rp.linearPoints(), rp.closed()) + p = newParsedPath(fitter.fit(o.Simplification)) } ops := make([]Op, 0) - segments := p.segments() - for i := range segments { - var prev *pathSegment - if i > 0 { - prev = &segments[i-1] + current, first := Point{}, Point{} + hasFirst := false + var previousCubicControl, previousQuadraticControl *Point + previousKey := "" + for _, segment := range p.segments { + key := segment.key + relative := key >= "a" && key <= "z" + upper := strings.ToUpper(key) + data := append([]float64(nil), segment.data...) + absolutePoint := func(x, y float64) Point { + if relative { + return Point{x + current[0], y + current[1]} + } + return Point{x, y} } - opList := processSegment(p, &segments[i], prev, o) - if len(opList) > 0 { - ops = append(ops, opList...) + switch upper { + case "M": + current = absolutePoint(data[0], data[1]) + first, hasFirst = current, true + case "L": + to := absolutePoint(data[0], data[1]) + ops = append(ops, doubleLine(current[0], current[1], to[0], to[1], o)...) + current = to + case "H": + x := data[0] + if relative { + x += current[0] + } + to := Point{x, current[1]} + ops = append(ops, doubleLine(current[0], current[1], to[0], to[1], o)...) + current = to + case "V": + y := data[0] + if relative { + y += current[1] + } + to := Point{current[0], y} + ops = append(ops, doubleLine(current[0], current[1], to[0], to[1], o)...) + current = to + case "C": + c1 := absolutePoint(data[0], data[1]) + c2 := absolutePoint(data[2], data[3]) + to := absolutePoint(data[4], data[5]) + ops = append(ops, bezierToCurrent(c1, c2, to, current, o)...) + current = to + copy := c2 + previousCubicControl = © + case "S": + c1 := current + if (previousKey == "C" || previousKey == "S") && previousCubicControl != nil { + c1 = Point{2*current[0] - (*previousCubicControl)[0], 2*current[1] - (*previousCubicControl)[1]} + } + c2 := absolutePoint(data[0], data[1]) + to := absolutePoint(data[2], data[3]) + ops = append(ops, bezierToCurrent(c1, c2, to, current, o)...) + current = to + copy := c2 + previousCubicControl = © + case "Q": + q := absolutePoint(data[0], data[1]) + to := absolutePoint(data[2], data[3]) + c1 := Point{current[0] + (q[0]-current[0])*2/3, current[1] + (q[1]-current[1])*2/3} + c2 := Point{to[0] + (q[0]-to[0])*2/3, to[1] + (q[1]-to[1])*2/3} + ops = append(ops, bezierToCurrent(c1, c2, to, current, o)...) + current = to + copy := q + previousQuadraticControl = © + case "T": + q := current + if (previousKey == "Q" || previousKey == "T") && previousQuadraticControl != nil { + q = Point{2*current[0] - (*previousQuadraticControl)[0], 2*current[1] - (*previousQuadraticControl)[1]} + } + to := absolutePoint(data[0], data[1]) + c1 := Point{current[0] + (q[0]-current[0])*2/3, current[1] + (q[1]-current[1])*2/3} + c2 := Point{to[0] + (q[0]-to[0])*2/3, to[1] + (q[1]-to[1])*2/3} + ops = append(ops, bezierToCurrent(c1, c2, to, current, o)...) + current = to + copy := q + previousQuadraticControl = © + case "A": + to := absolutePoint(data[5], data[6]) + if to != current { + if data[0] == 0 || data[1] == 0 { + ops = append(ops, bezierToCurrent(current, to, to, current, o)...) + } else { + from := current + for _, curve := range arcToCubicCurves(current[0], current[1], to[0], to[1], math.Abs(data[0]), math.Abs(data[1]), data[2], data[3], data[4], nil) { + ops = append(ops, bezierToCurrent(Point{curve[0], curve[1]}, Point{curve[2], curve[3]}, Point{curve[4], curve[5]}, from, o)...) + from = Point{curve[4], curve[5]} + } + } + } + current = to + case "Z": + if hasFirst { + ops = append(ops, doubleLine(current[0], current[1], first[0], first[1], o)...) + current = first + } + } + if upper != "C" && upper != "S" { + previousCubicControl = nil + } + if upper != "Q" && upper != "T" { + previousQuadraticControl = nil } + previousKey = upper } return OpSet{Type: OpSetPath, Ops: ops} } +func arcToCubicCurves(x1, y1, x2, y2, r1, r2, angle, largeArcFlag, sweepFlag float64, recursive []float64) [][6]float64 { + angleRad := math.Pi * angle / 180 + params := make([]Point, 0) + var f1, f2, cx, cy float64 + if recursive != nil { + f1, f2, cx, cy = recursive[0], recursive[1], recursive[2], recursive[3] + } else { + x1, y1 = rotatePathPoint(x1, y1, -angleRad) + x2, y2 = rotatePathPoint(x2, y2, -angleRad) + x := (x1 - x2) / 2 + y := (y1 - y2) / 2 + h := x*x/(r1*r1) + y*y/(r2*r2) + if h > 1 { + h = math.Sqrt(h) + r1 *= h + r2 *= h + } + sign := 1.0 + if largeArcFlag == sweepFlag { + sign = -1 + } + r1p, r2p := r1*r1, r2*r2 + left := r1p*r2p - r1p*y*y - r2p*x*x + right := r1p*y*y + r2p*x*x + k := sign * math.Sqrt(math.Abs(left/right)) + cx = k*r1*y/r2 + (x1+x2)/2 + cy = k*-r2*x/r1 + (y1+y2)/2 + f1 = math.Asin(jsToFixedNumber((y1-cy)/r2, 9)) + f2 = math.Asin(jsToFixedNumber((y2-cy)/r2, 9)) + if x1 < cx { + f1 = math.Pi - f1 + } + if x2 < cx { + f2 = math.Pi - f2 + } + if f1 < 0 { + f1 = math.Pi*2 + f1 + } + if f2 < 0 { + f2 = math.Pi*2 + f2 + } + if sweepFlag != 0 && f1 > f2 { + f1 -= math.Pi * 2 + } + if sweepFlag == 0 && f2 > f1 { + f2 -= math.Pi * 2 + } + } + df := f2 - f1 + if math.Abs(df) > math.Pi*120/180 { + f2old, x2old, y2old := f2, x2, y2 + if sweepFlag != 0 && f2 > f1 { + f2 = f1 + math.Pi*120/180 + } else { + f2 = f1 - math.Pi*120/180 + } + x2 = cx + r1*math.Cos(f2) + y2 = cy + r2*math.Sin(f2) + for _, curve := range arcToCubicCurves(x2, y2, x2old, y2old, r1, r2, angle, 0, sweepFlag, []float64{f2, f2old, cx, cy}) { + params = append(params, Point{curve[0], curve[1]}, Point{curve[2], curve[3]}, Point{curve[4], curve[5]}) + } + } + df = f2 - f1 + c1, s1, c2, s2 := math.Cos(f1), math.Sin(f1), math.Cos(f2), math.Sin(f2) + t := math.Tan(df / 4) + hx, hy := 4.0/3*r1*t, 4.0/3*r2*t + m1 := Point{x1, y1} + m2 := Point{x1 + hx*s1, y1 - hy*c1} + m3 := Point{x2 + hx*s2, y2 - hy*c2} + m4 := Point{x2, y2} + m2 = Point{2*m1[0] - m2[0], 2*m1[1] - m2[1]} + params = append([]Point{m2, m3, m4}, params...) + if recursive != nil { + curves := make([][6]float64, 0, len(params)/3) + for i := 0; i+2 < len(params); i += 3 { + curves = append(curves, [6]float64{params[i][0], params[i][1], params[i+1][0], params[i+1][1], params[i+2][0], params[i+2][1]}) + } + return curves + } + curves := make([][6]float64, 0, len(params)/3) + for i := 0; i+2 < len(params); i += 3 { + a, b := rotatePathPoint(params[i][0], params[i][1], angleRad) + c, d := rotatePathPoint(params[i+1][0], params[i+1][1], angleRad) + e, f := rotatePathPoint(params[i+2][0], params[i+2][1], angleRad) + curves = append(curves, [6]float64{a, b, c, d, e, f}) + } + return curves +} + +func rotatePathPoint(x, y, angle float64) (float64, float64) { + return x*math.Cos(angle) - y*math.Sin(angle), x*math.Sin(angle) + y*math.Cos(angle) +} + +func bezierToCurrent(c1, c2, to, current Point, o *ResolvedOptions) []Op { + ros0 := jsOr(o.MaxRandomnessOffset, 1) + ros := [2]float64{ros0, ros0 + 0.3} + iterations := 2 + if o.DisableMultiStroke { + iterations = 1 + } + ops := make([]Op, 0, iterations*2) + for i := 0; i < iterations; i++ { + move := current + if i != 0 && !o.PreserveVertices { + move = Point{current[0] + offsetOpt(ros[0], o), current[1] + offsetOpt(ros[0], o)} + } + end := to + if !o.PreserveVertices { + end = Point{to[0] + offsetOpt(ros[i], o), to[1] + offsetOpt(ros[i], o)} + } + ops = append(ops, + Op{Op: OpMove, Data: []float64{move[0], move[1]}}, + Op{Op: OpBCurveTo, Data: []float64{ + c1[0] + offsetOpt(ros[i], o), c1[1] + offsetOpt(ros[i], o), + c2[0] + offsetOpt(ros[i], o), c2[1] + offsetOpt(ros[i], o), + end[0], end[1], + }}, + ) + } + return ops +} + func solidFillPolygon(points []Point, o *ResolvedOptions) OpSet { + return solidFillPolygons([][]Point{points}, o) +} + +func solidFillPolygons(polygonList [][]Point, o *ResolvedOptions) OpSet { ops := make([]Op, 0) - if len(points) > 0 { + for _, points := range polygonList { randOffset := jsOr(o.MaxRandomnessOffset, 0) if len(points) > 2 { ops = append(ops, Op{Op: OpMove, Data: []float64{ @@ -248,19 +474,34 @@ func randOffsetWithRange(minimum, maximum float64, o *ResolvedOptions) float64 { } func doubleLineOps(x1, y1, x2, y2 float64, o *ResolvedOptions) []Op { - return doubleLine(x1, y1, x2, y2, o) + return doubleLineFill(x1, y1, x2, y2, o) } -func offset(minimum, maximum float64, o *ResolvedOptions) float64 { - return o.Roughness * o.RoughnessGain * (random(o)*(maximum-minimum) + minimum) +func offset(minimum, maximum float64, o *ResolvedOptions, roughnessGain ...float64) float64 { + gain := 1.0 + if len(roughnessGain) != 0 { + gain = roughnessGain[0] + } + return o.Roughness * gain * (random(o)*(maximum-minimum) + minimum) } -func offsetOpt(x float64, o *ResolvedOptions) float64 { - return offset(-x, x, o) +func offsetOpt(x float64, o *ResolvedOptions, roughnessGain ...float64) float64 { + return offset(-x, x, o, roughnessGain...) } func doubleLine(x1, y1, x2, y2 float64, o *ResolvedOptions) []Op { + return doubleLineMode(x1, y1, x2, y2, o, false) +} + +func doubleLineFill(x1, y1, x2, y2 float64, o *ResolvedOptions) []Op { + return doubleLineMode(x1, y1, x2, y2, o, true) +} + +func doubleLineMode(x1, y1, x2, y2 float64, o *ResolvedOptions, filling bool) []Op { o1 := roughLine(x1, y1, x2, y2, o, true, false) + if (!filling && o.DisableMultiStroke) || (filling && o.DisableMultiStrokeFill) { + return o1 + } o2 := roughLine(x1, y1, x2, y2, o, true, true) return append(o1, o2...) } @@ -268,16 +509,17 @@ func doubleLine(x1, y1, x2, y2 float64, o *ResolvedOptions) []Op { func roughLine(x1, y1, x2, y2 float64, o *ResolvedOptions, move, overlay bool) []Op { lengthSq := math.Pow(x1-x2, 2) + math.Pow(y1-y2, 2) length := math.Sqrt(lengthSq) + roughnessGain := 1.0 if length < 200 { - o.RoughnessGain = 1 + roughnessGain = 1 } else if length > 500 { - o.RoughnessGain = 0.4 + roughnessGain = 0.4 } else { // JavaScript rounds the multiplication before the addition. The // jsNumberStep prevents an architecture-dependent fused multiply-add // at the observable 200/500 length boundaries. scaledLength := jsNumberStep(-0.0016668 * length) - o.RoughnessGain = scaledLength + 1.233334 + roughnessGain = scaledLength + 1.233334 } randOffset := jsOr(o.MaxRandomnessOffset, 0) @@ -288,45 +530,51 @@ func roughLine(x1, y1, x2, y2 float64, o *ResolvedOptions, move, overlay bool) [ divergePoint := 0.2 + random(o)*0.2 midDispX := o.Bowing * o.MaxRandomnessOffset * (y2 - y1) / 200 midDispY := o.Bowing * o.MaxRandomnessOffset * (x1 - x2) / 200 - midDispX = offsetOpt(midDispX, o) - midDispY = offsetOpt(midDispY, o) + midDispX = offsetOpt(midDispX, o, roughnessGain) + midDispY = offsetOpt(midDispY, o, roughnessGain) + randomHalf := func() float64 { return offsetOpt(halfOffset, o, roughnessGain) } + randomFull := func() float64 { return offsetOpt(randOffset, o, roughnessGain) } + preserveVertices := o.PreserveVertices ops := make([]Op, 0, 2) if move { if overlay { ops = append(ops, Op{Op: OpMove, Data: []float64{ - x1 + offsetOpt(halfOffset, o), - y1 + offsetOpt(halfOffset, o), + x1 + jsConditionalFloat(preserveVertices, 0, randomHalf), + y1 + jsConditionalFloat(preserveVertices, 0, randomHalf), }}) } else { ops = append(ops, Op{Op: OpMove, Data: []float64{ - x1 + offsetOpt(randOffset, o), - y1 + offsetOpt(randOffset, o), + x1 + jsConditionalFloat(preserveVertices, 0, randomFull), + y1 + jsConditionalFloat(preserveVertices, 0, randomFull), }}) } } if overlay { ops = append(ops, Op{Op: OpBCurveTo, Data: []float64{ - midDispX + x1 + (x2-x1)*divergePoint + offsetOpt(halfOffset, o), - midDispY + y1 + (y2-y1)*divergePoint + offsetOpt(halfOffset, o), - midDispX + x1 + 2*(x2-x1)*divergePoint + offsetOpt(halfOffset, o), - midDispY + y1 + 2*(y2-y1)*divergePoint + offsetOpt(halfOffset, o), - x2 + offsetOpt(halfOffset, o), - y2 + offsetOpt(halfOffset, o), + midDispX + x1 + (x2-x1)*divergePoint + randomHalf(), + midDispY + y1 + (y2-y1)*divergePoint + randomHalf(), + midDispX + x1 + 2*(x2-x1)*divergePoint + randomHalf(), + midDispY + y1 + 2*(y2-y1)*divergePoint + randomHalf(), + x2 + jsConditionalFloat(preserveVertices, 0, randomHalf), + y2 + jsConditionalFloat(preserveVertices, 0, randomHalf), }}) } else { ops = append(ops, Op{Op: OpBCurveTo, Data: []float64{ - midDispX + x1 + (x2-x1)*divergePoint + offsetOpt(randOffset, o), - midDispY + y1 + (y2-y1)*divergePoint + offsetOpt(randOffset, o), - midDispX + x1 + 2*(x2-x1)*divergePoint + offsetOpt(randOffset, o), - midDispY + y1 + 2*(y2-y1)*divergePoint + offsetOpt(randOffset, o), - x2 + offsetOpt(randOffset, o), - y2 + offsetOpt(randOffset, o), + midDispX + x1 + (x2-x1)*divergePoint + randomFull(), + midDispY + y1 + (y2-y1)*divergePoint + randomFull(), + midDispX + x1 + 2*(x2-x1)*divergePoint + randomFull(), + midDispY + y1 + 2*(y2-y1)*divergePoint + randomFull(), + x2 + jsConditionalFloat(preserveVertices, 0, randomFull), + y2 + jsConditionalFloat(preserveVertices, 0, randomFull), }}) } return ops } func curveWithOffset(points []Point, randOffset float64, o *ResolvedOptions) []Op { + if len(points) == 0 { + return []Op{} + } ps := make([]Point, 0, len(points)+2) ps = append(ps, Point{points[0][0] + offsetOpt(randOffset, o), points[0][1] + offsetOpt(randOffset, o)}, @@ -397,6 +645,20 @@ func roughCurve(points []Point, closePoint *Point, o *ResolvedOptions) []Op { func computeEllipsePoints(increment, cx, cy, rx, ry, randOffset, overlap float64, o *ResolvedOptions) ([]Point, []Point) { corePoints := make([]Point, 0) allPoints := make([]Point, 0) + if o.Roughness == 0 { + increment /= 4 + allPoints = append(allPoints, Point{cx + rx*math.Cos(-increment), cy + ry*math.Sin(-increment)}) + for angle := 0.0; angle <= math.Pi*2; angle += increment { + p := Point{cx + rx*math.Cos(angle), cy + ry*math.Sin(angle)} + corePoints = append(corePoints, p) + allPoints = append(allPoints, p) + } + allPoints = append(allPoints, + Point{cx + rx, cy}, + Point{cx + rx*math.Cos(increment), cy + ry*math.Sin(increment)}, + ) + return allPoints, corePoints + } radOffset := offsetOpt(0.5, o) - math.Pi/2 allPoints = append(allPoints, Point{ @@ -448,19 +710,27 @@ func roughArc(increment, cx, cy, rx, ry, strt, stp, randOffset float64, o *Resol func bezierTo(x1, y1, x2, y2, x, y float64, path *roughPath, o *ResolvedOptions) []Op { ros0 := jsOr(o.MaxRandomnessOffset, 1) - ros := [2]float64{ros0, ros0 + 0.5} + ros := [2]float64{ros0, ros0 + 0.3} ops := make([]Op, 0, 4) f := Point{0, 0} - for i := 0; i < 2; i++ { + iterations := 2 + if o.DisableMultiStroke { + iterations = 1 + } + for i := 0; i < iterations; i++ { if i == 0 { ops = append(ops, Op{Op: OpMove, Data: []float64{path.x(), path.y()}}) } else { ops = append(ops, Op{Op: OpMove, Data: []float64{ - path.x() + offsetOpt(ros[0], o), - path.y() + offsetOpt(ros[0], o), + path.x() + jsConditionalFloat(o.PreserveVertices, 0, func() float64 { return offsetOpt(ros[0], o) }), + path.y() + jsConditionalFloat(o.PreserveVertices, 0, func() float64 { return offsetOpt(ros[0], o) }), }}) } - f = Point{x + offsetOpt(ros[i], o), y + offsetOpt(ros[i], o)} + if o.PreserveVertices { + f = Point{x, y} + } else { + f = Point{x + offsetOpt(ros[i], o), y + offsetOpt(ros[i], o)} + } ops = append(ops, Op{Op: OpBCurveTo, Data: []float64{ x1 + offsetOpt(ros[i], o), y1 + offsetOpt(ros[i], o), x2 + offsetOpt(ros[i], o), y2 + offsetOpt(ros[i], o), @@ -471,6 +741,22 @@ func bezierTo(x1, y1, x2, y2, x, y float64, path *roughPath, o *ResolvedOptions) return ops } +func cloneOptionsAlterSeed(options *ResolvedOptions) *ResolvedOptions { + copy := cloneResolvedOptions(*options) + copy.randomizer = nil + if options.Seed != 0 { + copy.Seed = options.Seed + 1 + } + return © +} + +func jsConditionalFloat(condition bool, whenTrue float64, whenFalse func() float64) float64 { + if condition { + return whenTrue + } + return whenFalse() +} + func processSegment(path *roughPath, seg, prevSeg *pathSegment, o *ResolvedOptions) []Op { ops := make([]Op, 0) switch seg.key { diff --git a/renderer_test.go b/renderer_test.go index 4926495..ccf10c8 100644 --- a/renderer_test.go +++ b/renderer_test.go @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Derived from Rough.js 4.0.4, Copyright (c) 2019 Preet Shihn. +// Derived from Rough.js 4.6.6, Copyright (c) 2019 Preet Shihn. package rough @@ -15,7 +15,7 @@ func rendererTestOptions(seed float64) *ResolvedOptions { return &o } -func TestRendererSeededLineMatchesRoughJS404(t *testing.T) { +func TestRendererSeededLineMatchesRoughJS466(t *testing.T) { o := rendererTestOptions(1) got := line(0, 0, 100, 50, o) want := OpSet{Type: OpSetPath, Ops: []Op{ @@ -33,7 +33,7 @@ func TestRendererSeededLineMatchesRoughJS404(t *testing.T) { }}, }} if !reflect.DeepEqual(got, want) { - t.Fatalf("seeded line differs from the frozen Rough.js 4.0.4 oracle:\n got: %#v\nwant: %#v", got, want) + t.Fatalf("seeded line differs from the Rough.js 4.6.6 oracle:\n got: %#v\nwant: %#v", got, want) } } @@ -112,7 +112,7 @@ func TestRendererPrimitiveOperationOrder(t *testing.T) { gotTypes[i] = got.Ops[i].Op } if !reflect.DeepEqual(gotTypes, tt.ops) { - t.Fatalf("operation order differs from Rough.js 4.0.4:\n got: %v\nwant: %v", gotTypes, tt.ops) + t.Fatalf("operation order differs from Rough.js 4.6.6:\n got: %v\nwant: %v", gotTypes, tt.ops) } }) } @@ -133,21 +133,12 @@ func TestRendererOutlineThenSolidFillSharesRandomStream(t *testing.T) { } } -func TestRendererRoughnessGainThresholds(t *testing.T) { - tests := []struct { - length float64 - want float64 - }{ - {length: 199, want: 1}, - {length: 200, want: 0.8999739999999999}, - {length: 500, want: 0.3999339999999999}, - {length: 501, want: 0.4}, - } - for _, tt := range tests { +func TestRendererRoughnessGainDoesNotMutateOptions(t *testing.T) { + for _, length := range []float64{199, 200, 500, 501} { o := rendererTestOptions(1) - _ = roughLine(0, 0, tt.length, 0, o, true, false) - if o.RoughnessGain != tt.want { - t.Errorf("length %v: got roughnessGain %v, want %v", tt.length, o.RoughnessGain, tt.want) + _ = roughLine(0, 0, length, 0, o, true, false) + if o.RoughnessGain != 1 { + t.Errorf("length %v mutated compatibility field to %v", length, o.RoughnessGain) } } } @@ -156,7 +147,7 @@ func TestRotatedHachureLineKeepsJavaScriptLengthBoundary(t *testing.T) { lines := []Line{{{-115, 189.83001211997961}, {85, 189.83001211997961}}} rotateLines(lines, Point{}, -66) if got, want := lineLength(lines[0]), 199.99999999999997; got != want { - t.Fatalf("rotated length = %.17g, want D2/Goja result %.17g", got, want) + t.Fatalf("rotated length = %.17g, want JavaScript result %.17g", got, want) } o := rendererTestOptions(1) @@ -170,12 +161,11 @@ func TestRendererSVGPathCommandOrder(t *testing.T) { path := "M10 20 l5 6 c1 2 3 4 5 6 s7 8 9 10 q2 3 4 5 t6 7 a4 5 30 0 1 8 9 z" got := svgPath(path, rendererTestOptions(1)) wantTypes := []OpType{ - OpMove, OpMove, OpBCurveTo, OpMove, OpBCurveTo, OpMove, OpBCurveTo, OpMove, OpBCurveTo, OpMove, OpBCurveTo, OpMove, OpBCurveTo, - OpMove, OpQCurveTo, OpMove, OpQCurveTo, - OpMove, OpQCurveTo, OpMove, OpQCurveTo, + OpMove, OpBCurveTo, OpMove, OpBCurveTo, + OpMove, OpBCurveTo, OpMove, OpBCurveTo, OpMove, OpBCurveTo, OpMove, OpBCurveTo, OpMove, OpBCurveTo, OpMove, OpBCurveTo, OpMove, OpBCurveTo, OpMove, OpBCurveTo, @@ -185,10 +175,10 @@ func TestRendererSVGPathCommandOrder(t *testing.T) { gotTypes[i] = got.Ops[i].Op } if !reflect.DeepEqual(gotTypes, wantTypes) { - t.Fatalf("SVG command operation order differs from Rough.js 4.0.4:\n got: %v\nwant: %v", gotTypes, wantTypes) + t.Fatalf("SVG command operation order differs from Rough.js 4.6.6:\n got: %v\nwant: %v", gotTypes, wantTypes) } - if got.Ops[0].Data[0] != 8.000089911743999 || got.Ops[0].Data[1] != 18.340129794552922 { - t.Fatalf("seeded SVG move differs from Rough.js 4.0.4: %v", got.Ops[0].Data) + if got.Ops[0].Data[0] != 10.33477207823485 || got.Ops[0].Data[1] != 20.376409023599454 { + t.Fatalf("seeded SVG move differs from Rough.js 4.6.6: %v", got.Ops[0].Data) } } diff --git a/testdata/differential/oracle.js b/testdata/differential/oracle.js index 07378a4..1245776 100644 --- a/testdata/differential/oracle.js +++ b/testdata/differential/oracle.js @@ -5,7 +5,7 @@ const vm = require("vm"); const bundlePath = process.env.ROUGH_GO_ROUGH_JS; if (!bundlePath) { - throw new Error("ROUGH_GO_ROUGH_JS must point to D2's frozen Rough.js bundle"); + throw new Error("ROUGH_GO_ROUGH_JS must point to the pinned Rough.js 4.6.6 browser bundle"); } const bundle = fs.readFileSync(bundlePath, "utf8"); @@ -39,6 +39,15 @@ const optionKeys = [ "zigzagOffset", "seed", "roughnessGain", + "strokeLineDash", + "strokeLineDashOffset", + "fillLineDash", + "fillLineDashOffset", + "disableMultiStroke", + "disableMultiStrokeFill", + "preserveVertices", + "fixedDecimalPlaceDigits", + "fillShapeRoughnessGain", ]; function cleanOptions(options) { @@ -52,9 +61,6 @@ function cleanOptions(options) { options, "randomizer", ); - if (out.randomizerPresent) { - out.randomizerSeed = options.randomizer.seed; - } return out; } From 1ecc107f7e5cb32eecebbc76ffe28666852413c5 Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Tue, 11 Aug 2026 20:07:07 -0700 Subject: [PATCH 2/2] test: make hachure regression deterministic --- filler_scan_line_hachure_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/filler_scan_line_hachure_test.go b/filler_scan_line_hachure_test.go index 87aeff4..e30e446 100644 --- a/filler_scan_line_hachure_test.go +++ b/filler_scan_line_hachure_test.go @@ -93,6 +93,9 @@ func TestPolygonHachureLinesStabilizesCollapsedArcEndpointOrdinate(t *testing.T) o := defaultResolvedOptions() o.HachureAngle = -7.7643498276563605 o.HachureGap = 6.942933222217832 + 2.514681068860853 + // Seed the skip-offset branch so this geometry regression is independent + // of the package-level Math.random-compatible source. + o.Seed = 1 lines := polygonHachureLines(points, &o) wantLengths := []float64{0, 11, 19, 21, 43, 29}