diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ed7de..10f3514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Added +- `Polygons#normalizeWinding(direction?)` method that flips the vertex order of each contour so all contours share a single winding direction. Useful before passing the rendered polygons to a renderer that uses non-zero filling (default for SVG `` and TrueType `glyf`), where mixed winding produces white-out artefacts at stroke intersections. Defaults to `"cw"`. +- `WindingDirection` type alias (`"cw" | "ccw"`) exported from the entry point. ## [0.6.1] - 2026-03-08 ### Fixed diff --git a/src/index.ts b/src/index.ts index 1618b28..69d2728 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,3 +5,4 @@ export { KShotai } from "./font/index.js"; export type { Font, Mincho, Gothic } from "./font/index.js"; export type { Polygon, Point } from "./polygon.js"; +export type { WindingDirection } from "./polygons.js"; diff --git a/src/polygons.ts b/src/polygons.ts index fc93efc..c110bca 100644 --- a/src/polygons.ts +++ b/src/polygons.ts @@ -1,5 +1,15 @@ import { Polygon } from "./polygon.js"; +/** + * The winding direction of a polygon's vertex order. Used by + * {@link Polygons.normalizeWinding} to choose the target orientation. + * + * - `"cw"` — clockwise (in a y-axis-down coordinate system, i.e. the system + * used internally by KAGE — `signedArea > 0`). + * - `"ccw"` — counter-clockwise (`signedArea < 0`). + */ +export type WindingDirection = "cw" | "ccw"; + /** * Represents a rendered glyph. * @@ -73,6 +83,51 @@ export class Polygons { } } + /** + * Reverses the vertex order of any contour whose signed area does not match + * the requested {@link WindingDirection}, so all contours share a single + * winding orientation. + * + * KAGE assembles each stroke as an independent closed polygon and pushes it + * onto {@link array} without normalizing winding. This is invisible in + * environments that fill with `evenodd` rules, but produces white-out + * artefacts at stroke intersections under non-zero filling — which is the + * default for both SVG `` (when no `fill-rule` is set) and TrueType + * `glyf`. Calling this method before passing the polygons to such a + * renderer ensures overlapping strokes render as a single filled shape. + * + * @param direction - Target winding direction. Defaults to `"cw"`, which + * matches the convention used by TrueType `glyf` outer contours when the + * coordinates are flipped to a y-up system. Use `"ccw"` for renderers that + * follow the SVG / KAGE-internal y-down convention. + * + * @example Preparing polygons for a TrueType `glyf` writer (y-up): + * ```ts + * const polygons = new Polygons(); + * kage.makeGlyph(polygons, "u9f8d"); + * polygons.normalizeWinding("ccw"); // KAGE-internal y-down "ccw" + * // → glyf y-up "cw" outer contours + * ``` + * + * @example Producing an SVG `` with non-zero filling: + * ```ts + * polygons.normalizeWinding(); + * const svg = polygons.generateSVG(true); + * ``` + */ + public normalizeWinding(direction: WindingDirection = "cw"): void { + for (const polygon of this.array) { + const area = signedArea(polygon); + // Empty / degenerate polygons (area === 0) are left untouched — + // reversing them has no observable effect. + if (area === 0) continue; + // KAGE uses a y-axis-down coordinate system (see signedArea). + const isCw = area > 0; + const wantCw = direction === "cw"; + if (isCw !== wantCw) polygon.reverse(); + } + } + /** * Generates a string in SVG format that represents the rendered glyph. * @param curve - Set to true to use the `` format, or set to false to @@ -165,3 +220,28 @@ export class Polygons { } } } + +/** + * Twice the signed area of a polygon, computed via the shoelace formula on the + * on-curve / off-curve vertex sequence. Off-curve points are included as if + * they were on-curve; this is sufficient for orientation detection (the sign + * of the result is unchanged by treating control points as polygon vertices) + * and avoids depending on the curve flattening machinery. + * + * In KAGE's y-axis-down coordinate system: + * - positive result → clockwise vertex order + * - negative result → counter-clockwise vertex order + * - zero result → degenerate polygon (collinear or empty) + */ +function signedArea(polygon: Polygon): number { + const pts = polygon.array; + const n = pts.length; + if (n < 3) return 0; + let s = 0; + for (let i = 0; i < n; i++) { + const a = pts[i]; + const b = pts[(i + 1) % n]; + s += (b.x - a.x) * (b.y + a.y); + } + return s; +} diff --git a/test/index.js b/test/index.js index 045bcb7..b4187bc 100644 --- a/test/index.js +++ b/test/index.js @@ -212,3 +212,82 @@ testKage({ [[94, 29], [94.2, 26.5], [94.6, 24], [95.4, 21.6], [96.7, 19.3], [98.3, 17.3], [100.3, 15.7], [102.6, 14.4], [105, 13.6], [107.5, 13.2], [110, 13], [110, 25], [108.8, 25.1], [107.9, 25.3], [107.3, 25.5], [107, 25.6], [106.8, 25.8], [106.6, 26], [106.5, 26.3], [106.3, 26.9], [106.1, 27.8], [106, 29]], [[110, 19], [110, 25], [130, 25], [130, 22]], ]); + +// ─── Polygons#normalizeWinding tests ──────────────────────────────── + +function assertNW(cond, msg) { + if (!cond) { + throw new Error(`Assertion failed: ${msg}`); + } +} + +function signedAreaOfPoints(pts) { + let s = 0; + const n = pts.length; + for (let i = 0; i < n; i++) { + const a = pts[i]; + const b = pts[(i + 1) % n]; + s += (b.x - a.x) * (b.y + a.y); + } + return s; +} + +// Render a glyph that produces multiple polygons with mixed winding, then +// verify that normalizeWinding leaves them all with the same orientation. +{ + const kage = new Kage(); + const polygons = new Polygons(); + // 龍 — many overlapping strokes; mixed winding is observed in output. + kage.kBuhin.push("u9f8d", "1:0:2:26:32:158:32$2:22:7:158:32:133:54:100:78$1:0:4:100:74:100:181"); + kage.makeGlyph(polygons, "u9f8d"); + assertNW(polygons.array.length > 0, "the test glyph produces at least one polygon"); + + // Default direction: cw. + polygons.normalizeWinding(); + for (const poly of polygons.array) { + const area = signedAreaOfPoints(poly.array); + // Empty / collinear polygons with area === 0 are allowed (untouched). + assertNW(area >= 0, `cw normalization keeps area >= 0 (got ${area})`); + } + + // Switch to ccw and verify all polygons flip. + polygons.normalizeWinding("ccw"); + for (const poly of polygons.array) { + const area = signedAreaOfPoints(poly.array); + assertNW(area <= 0, `ccw normalization keeps area <= 0 (got ${area})`); + } +} + +// Idempotency: applying normalizeWinding twice yields the same result as once. +{ + const kage = new Kage(); + const polygons1 = new Polygons(); + const polygons2 = new Polygons(); + kage.kBuhin.push("u9f8d", "1:0:2:26:32:158:32$2:22:7:158:32:133:54:100:78$1:0:4:100:74:100:181"); + kage.makeGlyph(polygons1, "u9f8d"); + kage.makeGlyph(polygons2, "u9f8d"); + polygons1.normalizeWinding("cw"); + polygons2.normalizeWinding("cw"); + polygons2.normalizeWinding("cw"); + assertNW(polygons1.array.length === polygons2.array.length, "same polygon count"); + for (let i = 0; i < polygons1.array.length; i++) { + const a1 = polygons1.array[i].array; + const a2 = polygons2.array[i].array; + assertNW(a1.length === a2.length, `polygon ${i}: same vertex count`); + for (let j = 0; j < a1.length; j++) { + assertNW( + Math.abs(a1[j].x - a2[j].x) < 1e-6 && Math.abs(a1[j].y - a2[j].y) < 1e-6, + `polygon ${i} vertex ${j}: same coordinates after re-normalization` + ); + } + } +} + +// Empty / degenerate polygons are accepted without error. +{ + const polygons = new Polygons(); + polygons.normalizeWinding(); + assertNW(polygons.array.length === 0, "empty Polygons stays empty"); +} + +console.log("Polygons#normalizeWinding: ok");