Skip to content

Commit

Permalink
feat: implement fill-rule for path with libtess #29
Browse files Browse the repository at this point in the history
  • Loading branch information
xiaoiver committed Jan 24, 2025
1 parent 39dbdbd commit d59011a
Show file tree
Hide file tree
Showing 25 changed files with 399 additions and 35 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ pnpm run dev

- Experimenting with SDF
- Trying to draw fills using some triangulating methods and strokes using polylines
- Support earcut and libtess.js two triangulation schemes
- Handle holes in the path correctly
- Support `fillRule` property
- Draw some hand-drawn shapes

<img src="./screenshots/lesson13.png" width="300" alt="Lesson 13 - path">
Expand Down
3 changes: 3 additions & 0 deletions README.zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ pnpm run dev

- 尝试使用 SDF 绘制
- 通过三角化绘制填充部分,使用折线绘制描边部分
- 支持 earcut 和 libtess.js 两种三角化方案
- 正确处理路径中的孔洞
- 支持 `fillRule` 属性
- 实现一些手绘风格图形

<img src="./screenshots/lesson13.png" width="300" alt="Lesson 13 - path">
Expand Down
20 changes: 20 additions & 0 deletions __tests__/ssr/path.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,24 @@ describe('Path', () => {
'path-holes-libtess',
);
});

it('should render fill-rule correctly.', async () => {
const path = new Path({
d: 'M50 0 L21 90 L98 35 L2 35 L79 90 Z',
fill: '#F67676',
fillRule: 'evenodd',
tessellationMethod: TesselationMethod.LIBTESS,
});
canvas.appendChild(path);
canvas.render();

expect($canvas.getContext('webgl1')).toMatchWebGLSnapshot(
dir,
'path-fill-rule-evenodd',
);
expect(exporter.toSVG({ grid: true })).toMatchSVGSnapshot(
dir,
'path-fill-rule-evenodd',
);
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 35 additions & 0 deletions __tests__/ssr/snapshots/path-fill-rule-evenodd.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions __tests__/unit/serialize.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ describe('Serialize', () => {
cullable: true,
fill: 'none',
fillOpacity: 1,
fillRule: 'nonzero',
innerShadowBlurRadius: 0,
innerShadowColor: 'black',
innerShadowOffsetX: 0,
Expand Down
6 changes: 5 additions & 1 deletion __tests__/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import _gl from 'gl';
import { createCanvas } from 'canvas';
import { JSDOM } from 'jsdom';
import { XMLSerializer } from '@xmldom/xmldom';
import GraphemeSplitter from 'grapheme-splitter';
import { Adapter } from '../packages/core/src/environment';

export function sleep(n: number) {
Expand Down Expand Up @@ -84,7 +85,10 @@ export const NodeJSAdapter: Adapter = {
getXMLSerializer: () => new XMLSerializer(),
getDOMParser: () => null,
setCursor: () => {},
splitGraphemes: (s: string) => [...s],
splitGraphemes: (s: string) => {
const splitter = new GraphemeSplitter();
return splitter.splitGraphemes(s);
},
requestAnimationFrame: (callback: FrameRequestCallback) => {
const currTime = new Date().getTime();
const timeToCall = Math.max(0, 16 - (currTime - lastTime));
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/drawcalls/Mesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export class Mesh extends Drawcall {
this.points = points;
// const err = deviation(vertices, holes, dimensions, indices);
} else if (tessellationMethod === TesselationMethod.LIBTESS) {
const newPoints = triangulate(rawPoints);
const newPoints = triangulate(rawPoints, (instance as Path).fillRule);
this.indexBufferData = new Uint32Array(
new Array(newPoints.length / 2).fill(undefined).map((_, i) => i),
);
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/shapes/Path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export interface PathAttributes extends ShapeAttributes {
*/
d: string;

/**
* The fill rule to use for rendering the path.
* @see https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/fill-rule
*/
fillRule?: CanvasFillRule;

/**
* The tesselation method to use for rendering the path.
*/
Expand Down Expand Up @@ -59,10 +65,11 @@ export function PathWrapper<TBase extends GConstructor>(Base: TBase) {
constructor(attributes: Partial<PathAttributes> = {}) {
super(attributes);

const { d, tessellationMethod } = attributes;
const { d, tessellationMethod, fillRule } = attributes;

this.d = d;
this.tessellationMethod = tessellationMethod ?? TesselationMethod.EARCUT;
this.fillRule = fillRule ?? 'nonzero';
}

get d() {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/utils/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ const rectAttributes = [
'dropShadowBlurRadius',
] as const;
const polylineAttributes = ['points'] as const;
const pathAttributes = ['d'] as const;
const pathAttributes = ['d', 'fillRule'] as const;
const textAttributes = [
'x',
'y',
Expand Down Expand Up @@ -161,6 +161,7 @@ const defaultValues = {
dropShadowOffsetX: 0,
dropShadowOffsetY: 0,
dropShadowBlurRadius: 0,
fillRule: 'nonzero',
};

type CommonAttributeName = (
Expand Down
27 changes: 18 additions & 9 deletions packages/core/src/utils/tessy.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
/**
* @see https://github.com/brendankenny/libtess.js/blob/gh-pages/examples/osm/triangulate.js
* @see https://github.com/ShukantPal/pixi-essentials/blob/049c67d0126ca771e026a04702a63fee1ce25d16/packages/svg/src/utils/buildPath.ts#L12
*/
import libtess from 'libtess';

const tessy = new libtess.GluTesselator();
// tessy.gluTessProperty(libtess.gluEnum.GLU_TESS_WINDING_RULE, libtess.windingRule.GLU_TESS_WINDING_POSITIVE);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_VERTEX_DATA, vertexCallback);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_BEGIN, begincallback);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_ERROR, errorcallback);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_COMBINE, combinecallback);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_EDGE_FLAG, edgeCallback);

// function called for each vertex of tesselator output
function vertexCallback(data, polyVertArray) {
// console.log(data[0], data[1]);
Expand All @@ -36,7 +29,23 @@ function edgeCallback(flag) {
// console.log('edge flag: ' + flag);
}

export function triangulate(contours: [number, number][][]) {
export function triangulate(
contours: [number, number][][],
fillRule: CanvasFillRule,
) {
const tessy = new libtess.GluTesselator();
tessy.gluTessProperty(
libtess.gluEnum.GLU_TESS_WINDING_RULE,
fillRule === 'evenodd'
? libtess.windingRule.GLU_TESS_WINDING_ODD
: libtess.windingRule.GLU_TESS_WINDING_NONZERO,
);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_VERTEX_DATA, vertexCallback);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_BEGIN, begincallback);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_ERROR, errorcallback);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_COMBINE, combinecallback);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_EDGE_FLAG, edgeCallback);

// libtess will take 3d verts and flatten to a plane for tesselation
// since only doing 2d tesselation here, provide z=1 normal to skip
// iterating over verts only to get the same answer.
Expand Down
4 changes: 4 additions & 0 deletions packages/site/docs/.vitepress/config/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ export const en = defineConfig({
text: 'Draw holes in Path',
link: 'holes',
},
{
text: 'Fill rule',
link: 'fill-rule',
},
{
text: 'Use SDF to draw text',
link: 'sdf-text',
Expand Down
4 changes: 4 additions & 0 deletions packages/site/docs/.vitepress/config/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ export const zh = defineConfig({
text: '绘制 Path 中的孔洞',
link: 'holes',
},
{
text: '填充规则',
link: 'fill-rule',
},
{
text: '使用 SDF 绘制文本',
link: 'sdf-text',
Expand Down
65 changes: 65 additions & 0 deletions packages/site/docs/components/FillRule.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<script setup>
import { Path, TesselationMethod } from '@infinite-canvas-tutorial/core';
import '@infinite-canvas-tutorial/ui';
import { ref, onMounted } from 'vue';
import Stats from 'stats.js';
let canvas;
const stats = new Stats();
stats.showPanel(0);
const $stats = stats.dom;
$stats.style.position = 'absolute';
$stats.style.left = '0px';
$stats.style.top = '0px';
const wrapper = ref(null);
onMounted(() => {
const $canvas = wrapper.value;
if (!$canvas) return;
$canvas.parentElement.appendChild($stats);
$canvas.addEventListener('ic-ready', (e) => {
canvas = e.detail;
// fill-rule: nonzero
const star1 = new Path({
d: 'M50 0 L21 90 L98 35 L2 35 L79 90 Z',
fill: '#F67676',
// wireframe: true,
// tessellationMethod: TesselationMethod.EARCUT,
tessellationMethod: TesselationMethod.LIBTESS,
});
canvas.appendChild(star1);
star1.position.x = 100;
star1.position.y = 100;
// fill-rule: evenodd
const star2 = new Path({
d: 'M150 0 L121 90 L198 35 L102 35 L179 90 Z',
fill: '#F67676',
fillRule: 'evenodd',
// wireframe: true,
tessellationMethod: TesselationMethod.LIBTESS,
// stroke: 'green',
// strokeWidth: 2,
});
canvas.appendChild(star2);
star2.position.x = 300;
star2.position.y = 100;
});
$canvas.addEventListener('ic-frame', (e) => {
stats.update();
});
});
</script>

<template>
<div style="position: relative">
<ic-canvas ref="wrapper" style="height: 200px"></ic-canvas>
</div>
</template>
28 changes: 17 additions & 11 deletions packages/site/docs/components/Harfbuzz.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup>
import { Path } from '@infinite-canvas-tutorial/core';
import { Path, Group } from '@infinite-canvas-tutorial/core';
import '@infinite-canvas-tutorial/ui';
import { ref, onMounted, onUnmounted } from 'vue';
import Stats from 'stats.js';
Expand Down Expand Up @@ -44,17 +44,17 @@ onMounted(() => {
font.setVariations({ wdth: 200, wght: 700 });
buffer = hb.createBuffer();
buffer.addText('H');
buffer.addText('Hello, world!');
buffer.guessSegmentProperties();
// TODO: use BiDi
// buffer.setDirection(segment.direction);
// buffer.setDirection(hb.Direction.RTL);
hb.shape(font, buffer);
const result = buffer.json(font);
buffer.destroy();
const base = { x: 0, y: 0 };
const glyphs = new Array();
const glyphs = [];
for (const glyph of result) {
glyphs.push({
id: glyph.g,
Expand All @@ -64,20 +64,26 @@ onMounted(() => {
base.y += glyph.ay;
}
const bounds = { width: base.x, height: face.upem };
const root = new Group();
root.position.x = 100;
root.position.y = 100;
canvas.appendChild(root);
window.console.log(glyphs, bounds);
result.forEach(function (x) {
result.forEach(function (x, i) {
const d = font.glyphToPath(x.g);
const path = new Path({
d,
fill: '#F67676',
cullable: false,
});
canvas.appendChild(path);
root.appendChild(path);
const glyph = glyphs[i];
path.position.x = 100;
path.position.y = 100;
path.position.x = glyph.base.x;
path.position.y = glyph.base.y;
path.scale.x = 1;
path.scale.y = -1;
});
});
Expand Down
17 changes: 15 additions & 2 deletions packages/site/docs/components/Opentype.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,27 @@
import { Path, TesselationMethod } from '@infinite-canvas-tutorial/core';
import '@infinite-canvas-tutorial/ui';
import { ref, onMounted } from 'vue';
import Stats from 'stats.js';
import opentype from 'opentype.js';
let canvas;
const stats = new Stats();
stats.showPanel(0);
const $stats = stats.dom;
$stats.style.position = 'absolute';
$stats.style.left = '0px';
$stats.style.top = '0px';
const wrapper = ref(null);
onMounted(() => {
const $canvas = wrapper.value;
if (!$canvas) return;
$canvas.parentElement.appendChild($stats);
$canvas.addEventListener('ic-ready', async(e) => {
canvas = e.detail;
Expand All @@ -35,11 +45,14 @@ onMounted(() => {
const path = new Path({
d,
fill: '#F67676',
tessellationMethod: TesselationMethod.EARCUT,
// wireframe: true,
tessellationMethod: TesselationMethod.LIBTESS,
});
canvas.appendChild(path);
});
$canvas.addEventListener('ic-frame', (e) => {
stats.update();
});
});
</script>

Expand Down
Loading

0 comments on commit d59011a

Please sign in to comment.