Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<path>` 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
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
80 changes: 80 additions & 0 deletions src/polygons.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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 `<path>` (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 `<path>` 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 `<path />` format, or set to false to
Expand Down Expand Up @@ -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;
}
79 changes: 79 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Loading