diff --git a/v3/src/components/data-display/renderer/pixi-point-renderer.test.ts b/v3/src/components/data-display/renderer/pixi-point-renderer.test.ts index 798401d171..277d18dbda 100644 --- a/v3/src/components/data-display/renderer/pixi-point-renderer.test.ts +++ b/v3/src/components/data-display/renderer/pixi-point-renderer.test.ts @@ -1,3 +1,4 @@ +import { PointShapes } from "../../../utilities/point-shape-utils" import { CaseDataWithSubPlot } from "../d3-types" import { NullPointRenderer } from "./null-point-renderer" import { PixiPointRenderer } from "./pixi-point-renderer" @@ -18,6 +19,7 @@ jest.mock("pixi.js", () => { destroy() { this.children = [] } } class MockSprite { + hitArea: any = null anchor = { x: 0, y: 0, copyFrom(p: any) { this.x = p.x; this.y = p.y } } position = { x: 0, y: 0, set(x: number, y: number) { this.x = x; this.y = y } } scale = { x: 1, y: 1, set(x: number, y: number) { this.x = x; this.y = y } } @@ -39,10 +41,14 @@ jest.mock("pixi.js", () => { } class MockGraphics { boundsArea: any = null - rect() { return this } - circle() { return this } + // records what was traced, so a test can tell a polygon from an arc + traced: Array<{ op: string, args: any[] }> = [] + rect(...args: any[]) { this.traced.push({ op: "rect", args }); return this } + circle(...args: any[]) { this.traced.push({ op: "circle", args }); return this } + poly(...args: any[]) { this.traced.push({ op: "poly", args }); return this } fill() { return this } - stroke() { return this } + strokeOptions: any = null + stroke(options: any) { this.strokeOptions = options; return this } destroy() {} } class MockTicker { @@ -53,6 +59,9 @@ jest.mock("pixi.js", () => { destroy() {} } class MockTexture { + // what a real texture reports at scale 1: the frame it was generated from, or nothing when the + // graphics sized it themselves + constructor(public width?: number, public height?: number) {} destroy() {} } class MockRenderer { @@ -68,7 +77,12 @@ jest.mock("pixi.js", () => { } resize() {} render() {} - generateTexture() { return new MockTexture() } + // keeps every call, so a test can inspect the graphics traced and the frame requested + generateTextureCalls: any[] = [] + generateTexture(options: any) { + this.generateTextureCalls.push(options) + return new MockTexture(options.frame?.width, options.frame?.height) + } destroy() {} } return { @@ -96,6 +110,183 @@ describe("PixiPointRenderer", () => { subPlotNum }) + describe("point shapes", () => { + const setUp = async (style: IPointStyle) => { + const pixiRenderer = new PixiPointRenderer(new PointsState()) + await pixiRenderer.init() + pixiRenderer.matchPointsToData("dataset1", [createCaseData(0, "case1")], "points", style) + const renderer = (pixiRenderer as any).renderer + const sprite = (pixiRenderer as any).sprites.get( + (pixiRenderer as any).state.getPointIdForCaseData(createCaseData(0, "case1")) + ) + return { pixiRenderer, renderer, sprite } + } + + const lastTexture = (renderer: any) => + renderer.generateTextureCalls[renderer.generateTextureCalls.length - 1] + + it("traces the shape's polygon rather than an arc", async () => { + const { renderer } = await setUp({ ...defaultStyle, shape: "square" }) + const traced = lastTexture(renderer).target.traced.map((t: any) => t.op) + + expect(traced).toContain("poly") + expect(traced).not.toContain("circle") + }) + + it("still draws a circle as an arc, as it always has", async () => { + // the shape CODAP has always drawn keeps its own path, including sizing its own texture + const { renderer } = await setUp(defaultStyle) + const call = lastTexture(renderer) + + expect(call.target.traced.map((t: any) => t.op)).toContain("circle") + expect(call.frame).toBeUndefined() + }) + + it("centers a triangle's texture on the point rather than on its ink", async () => { + /* + * The sprite's anchor sits at the middle of its texture, so a texture sized to the ink would + * put the middle of a triangle's ink on the point -- and a triangle is centered on its center + * of area, which is not the middle of its outline. It would be drawn low. + */ + const { renderer } = await setUp({ ...defaultStyle, shape: "triangle" }) + const { frame } = lastTexture(renderer) + + expect(frame).toBeDefined() + expect(frame.x + frame.width / 2).toBeCloseTo(0, 6) + expect(frame.y + frame.height / 2).toBeCloseTo(0, 6) + }) + + it("frames every shape on whole units, which is all generateTexture can honor", async () => { + /* + * It truncates the frame's width and height but translates by the origin it was given, so a + * fractional frame leaves the shape drawn off the center its anchor assumes it is on. + */ + for (const shape of PointShapes.filter(s => s !== "circle")) { + const { renderer } = await setUp({ ...defaultStyle, shape, radius: 7 }) + const { frame } = lastTexture(renderer) + + expect(frame.width).toBe(Math.trunc(frame.width)) + expect(frame.height).toBe(Math.trunc(frame.height)) + // and centered on the point, which is what the frame is there for + expect(frame.x + frame.width / 2).toBeCloseTo(0, 6) + expect(frame.y + frame.height / 2).toBeCloseTo(0, 6) + } + }) + + it("rounds the stroke joins, as the canvas renderer does", async () => { + // a miter on the star's 36-degree tips reaches past the stroke padding and clips, and draws + // the spikes the canvas renderer rounds away there and at the X's corners + const { renderer } = await setUp({ ...defaultStyle, shape: "star" }) + + expect(lastTexture(renderer).target.strokeOptions.join).toBe("round") + }) + + it("gives two shapes two textures rather than sharing one", async () => { + // the texture cache keys on the whole style, so a shape cannot collide with another + const { pixiRenderer, renderer } = await setUp({ ...defaultStyle, shape: "square" }) + const before = renderer.generateTextureCalls.length + const pointId = (pixiRenderer as any).sprites.keys().next().value + ;(pixiRenderer as any).doSetPointStyle(pointId, { shape: "star" }) + + expect(renderer.generateTextureCalls.length).toBe(before + 1) + expect(lastTexture(renderer).target.traced.map((t: any) => t.op)).toContain("poly") + }) + + it("sizes the bars-to-points transition from the texture that replaces the bar", async () => { + /* + * The new texture arrives at scale 1, so the transition has to land on its real size. A + * star's is about 2.7r across; animating to 2r left the sprite to jump the rest of the way + * once the bar's texture was swapped in. + */ + const style: IPointStyle = { ...defaultStyle, shape: "star", radius: 8, width: 20, height: 40 } + const pixiRenderer = new PixiPointRenderer(new PointsState()) + await pixiRenderer.init() + const caseData = [createCaseData(0, "case1")] + pixiRenderer.matchPointsToData("dataset1", caseData, "bars", style) + pixiRenderer.matchPointsToData("dataset1", caseData, "points", style) + + const renderer = (pixiRenderer as any).renderer + const pointId = (pixiRenderer as any).sprites.keys().next().value + const sprite = (pixiRenderer as any).sprites.get(pointId) + ;(pixiRenderer as any).doSetPositionOrTransition(pointId, style, 5, 5) + + const target = (pixiRenderer as any).targetProp.scale.get(sprite) + const { frame } = lastTexture(renderer) + expect(frame).toBeDefined() + expect(target.x * sprite.width).toBeCloseTo(frame.width, 6) + expect(target.y * sprite.height).toBeCloseTo(frame.height, 6) + // which is not the 2r it used to animate to + expect(frame.width).toBeGreaterThan(2 * style.radius) + }) + + describe("hit area", () => { + it("tests the drawn shape rather than the sprite's rectangle", async () => { + const { sprite } = await setUp({ ...defaultStyle, shape: "star", radius: 8 }) + + // straight up along a tip, past the radius but on the ink + expect(sprite.hitArea.contains(0, -10)).toBe(true) + // the same distance out between two arms, where the star is not drawn + const rad = -54 * Math.PI / 180 + expect(sprite.hitArea.contains(Math.cos(rad) * 10, Math.sin(rad) * 10)).toBe(false) + }) + + it("applies the circle floor to a shape narrower than it", async () => { + // point-shapes.test.ts holds the floor across all seven shapes; what this checks is that a + // sprite reaches it, which a plus does at every angle through its notches + const { sprite } = await setUp({ ...defaultStyle, shape: "plus", radius: 8 }) + + for (let deg = 0; deg < 360; deg += 30) { + const rad = deg * Math.PI / 180 + expect(sprite.hitArea.contains(Math.cos(rad) * 7.9, Math.sin(rad) * 7.9)).toBe(true) + } + }) + + it("follows the radius when it changes", async () => { + // the outline and the reach it rejects against are held, so both have to be rebuilt here + const { pixiRenderer, sprite } = await setUp({ ...defaultStyle, shape: "circle", radius: 4 }) + expect(sprite.hitArea.contains(0, -6)).toBe(false) + + const pointId = (pixiRenderer as any).sprites.keys().next().value + ;(pixiRenderer as any).doSetPointStyle(pointId, { radius: 8 }) + + expect(sprite.hitArea.contains(0, -6)).toBe(true) + }) + + it("re-tests existing sprites against the shape they are redrawn with", async () => { + // matchPointsToData hands every existing sprite one texture; the hit area has to follow it + const { pixiRenderer, sprite } = await setUp({ ...defaultStyle, shape: "circle", radius: 8 }) + expect(sprite.hitArea.contains(0, -10)).toBe(false) + + pixiRenderer.matchPointsToData("dataset1", [createCaseData(0, "case1")], "points", + { ...defaultStyle, shape: "star", radius: 8 }) + + expect(sprite.hitArea.contains(0, -10)).toBe(true) + }) + + it("follows the shape when the style changes", async () => { + const { pixiRenderer, sprite } = await setUp({ ...defaultStyle, shape: "circle", radius: 8 }) + expect(sprite.hitArea.contains(0, -10)).toBe(false) + + const pointId = (pixiRenderer as any).sprites.keys().next().value + ;(pixiRenderer as any).doSetPointStyle(pointId, { shape: "star" }) + + // the tip is on the ink now, so the same click that missed the circle hits the star + expect(sprite.hitArea.contains(0, -10)).toBe(true) + }) + + it("leaves bars to the sprite's own rectangular test", async () => { + // a bar is a rectangle, which is exactly what a sprite hit tests against by default + const pixiRenderer = new PixiPointRenderer(new PointsState()) + await pixiRenderer.init() + pixiRenderer.matchPointsToData("dataset1", [createCaseData(0, "case1")], "bars", + { ...defaultStyle, width: 20, height: 40 }) + const sprite = (pixiRenderer as any).sprites.values().next().value + + expect(sprite.hitArea).toBeNull() + }) + }) + }) + describe("setPointsInteractive", () => { it("toggles hit-testing of the points container", async () => { const pixiRenderer = new PixiPointRenderer(new PointsState()) diff --git a/v3/src/components/data-display/renderer/pixi-point-renderer.ts b/v3/src/components/data-display/renderer/pixi-point-renderer.ts index b9688689c6..dd86dcc5da 100644 --- a/v3/src/components/data-display/renderer/pixi-point-renderer.ts +++ b/v3/src/components/data-display/renderer/pixi-point-renderer.ts @@ -12,6 +12,11 @@ import { } from "./point-renderer-base" import { PointsState } from "./points-state" import { coalesceBars, IBarPiece, pointStateToBarPiece } from "./bar-coalescing" +import { kDefaultPointShape, PointShape } from "../../../utilities/point-shape-utils" +import { + isPointInShapeGeometry, PointShapeGeometry, pointShapeBoundingRadius, pointShapeGeometry, + pointShapeSymmetricExtent +} from "./point-shapes" import { IBackgroundEventDistributionOptions, IPoint, @@ -21,6 +26,75 @@ import { RendererCapability } from "./point-renderer-types" +/* + * The region of the shape's own coordinates that becomes the texture. + * + * A sprite draws its texture around its anchor, which for points is the middle of the texture, so + * the texture has to be centered on the point rather than on the ink. Left to size itself it takes + * the bounds of the ink, and a triangle's ink sits high -- centering that would draw the triangle + * low by about a third of its radius. + * + * Padded by the stroke, which straddles the outline and would otherwise clip at the widest vertices. + * + * Rounded up to whole units, because generateTexture truncates the frame's width and height to + * integers but translates by the origin it was given: a frame 15.6 wide becomes a 15-wide texture + * holding the shape's center at 7.8, while the anchor puts the texture's own center at 7.5, so the + * shape draws 0.3px right of the position it is hit tested at. How far off depends on the shape, so + * a point given a new shape lands somewhere slightly different than it sat before. Rounding up + * rather than to nearest keeps the full stroke padding. + */ +function symmetricFrame(shape: PointShape, radius: number, strokeWidth: number): PIXI.Rectangle { + const { w, h } = pointShapeSymmetricExtent(shape, radius) + const paddedW = Math.ceil(w + 2 * strokeWidth) + const paddedH = Math.ceil(h + 2 * strokeWidth) + return new PIXI.Rectangle(-paddedW / 2, -paddedH / 2, paddedW, paddedH) +} + +/* + * Hit tests a sprite against the shape drawn on it rather than the rectangle of its texture, which + * is what a sprite falls back to and is looser than even the circle CODAP has always drawn. + * + * PIXI hands `contains` the pointer in the sprite's own coordinates, where the origin is the point's + * position, so these are the same offsets the canvas hit tester works in and both renderers agree + * on what counts as a hit. + * + * Everything this needs is held rather than derived, because PIXI's hitPruneFn calls `contains` on + * every interactive sprite with no cheaper bounds test of its own, and the sprites are all + * interactive: one pointer move over a dense plot is one call per point. Building a star's outline + * on each of them costs about 0.9ms across 5,000 points, nearly all of it spent answering misses. + */ +class PointShapeHitArea { + private geometry: PointShapeGeometry + // beyond this nothing can be on the point, which is the answer for nearly every call + private reachSq: number + + constructor(public shape: PointShape, public radius: number) { + this.geometry = pointShapeGeometry(shape, radius) + this.reachSq = PointShapeHitArea.reachSqFor(shape, radius) + } + + update(shape: PointShape, radius: number): void { + if (shape === this.shape && radius === this.radius) return + + this.shape = shape + this.radius = radius + this.geometry = pointShapeGeometry(shape, radius) + this.reachSq = PointShapeHitArea.reachSqFor(shape, radius) + } + + contains(x: number, y: number): boolean { + if (x * x + y * y > this.reachSq) return false + return isPointInShapeGeometry(this.geometry, this.radius, x, y) + } + + // the ink can stop short of r -- a plus does, in its notches -- and the containment test answers + // for the circle there, so the reach is whichever of the two goes further + private static reachSqFor(shape: PointShape, radius: number): number { + const reach = Math.max(radius, pointShapeBoundingRadius(shape, radius)) + return reach * reach + } +} + const DEFAULT_Z_INDEX = 0 const RAISED_Z_INDEX = 100 const MAX_SPRITE_SCALE = 2 @@ -450,7 +524,7 @@ export class PixiPointRenderer extends PointRendererBase { // Create sprites for added points (skip any already created by syncFromState above) added.forEach(pointId => { if (!this.sprites.has(pointId)) { - const sprite = this.getNewSprite(pointId, texture) + const sprite = this.getNewSprite(pointId, texture, style) this.pointsContainer.addChild(sprite) this.sprites.set(pointId, sprite) } @@ -458,8 +532,13 @@ export class PixiPointRenderer extends PointRendererBase { // Update existing sprites this.sprites.forEach((sprite, pointId) => { - if (!added.includes(pointId) && sprite.texture !== texture) { - sprite.texture = texture + if (!added.includes(pointId)) { + if (sprite.texture !== texture) { + sprite.texture = texture + } + // against the uniform style, which is the one that drew the texture just assigned -- a + // point's own stored style can still be the one it had under the previous display type + this.syncHitArea(sprite, style) } }) @@ -501,6 +580,7 @@ export class PixiPointRenderer extends PointRendererBase { if (sprite.texture !== texture) { sprite.texture = texture } + this.syncHitArea(sprite, newStyle) this.doStartRendering() } @@ -739,7 +819,7 @@ export class PixiPointRenderer extends PointRendererBase { if (!this.sprites.has(pointState.id)) { try { const texture = this.getPointTexture(pointState.style) - const sprite = this.getNewSprite(pointState.id, texture) + const sprite = this.getNewSprite(pointState.id, texture, pointState.style) sprite.position.set(pointState.x, pointState.y) sprite.scale.set(pointState.scale) sprite.zIndex = pointState.isRaised ? RAISED_Z_INDEX : DEFAULT_Z_INDEX @@ -752,14 +832,32 @@ export class PixiPointRenderer extends PointRendererBase { }) } - private getNewSprite(pointId: string, texture: PIXI.Texture): PIXI.Sprite { + private getNewSprite(pointId: string, texture: PIXI.Texture, style: IPointStyle): PIXI.Sprite { const sprite = new PIXI.Sprite(texture) sprite.anchor.copyFrom(this._anchor) sprite.zIndex = DEFAULT_Z_INDEX + this.syncHitArea(sprite, style) this.setupSpriteInteractivity(pointId, sprite) return sprite } + /* + * Bars keep the sprite's own rectangular test, which is what a bar is. Only a point carries a + * shape to test against. + */ + private syncHitArea(sprite: PIXI.Sprite, style: IPointStyle): void { + if (this._displayType !== "points") { + sprite.hitArea = null + return + } + const shape = style.shape ?? kDefaultPointShape + if (sprite.hitArea instanceof PointShapeHitArea) { + sprite.hitArea.update(shape, style.radius) + } else { + sprite.hitArea = new PointShapeHitArea(shape, style.radius) + } + } + private setPointXyProperty(prop: TransitionProp, sprite: PIXI.Sprite, x: number, y: number): void { if (this.currentTransition) { this.setTargetXyProp(prop, sprite, x, y) @@ -802,10 +900,10 @@ export class PixiPointRenderer extends PointRendererBase { private getPointTexture(style: IPointStyle, includeDimensions = false): PIXI.Texture { return this._displayType === "bars" ? this.getRectTexture(style, includeDimensions) - : this.getCircleTexture(style) + : this.getShapeTexture(style) } - private getCircleTexture(style: IPointStyle): PIXI.Texture { + private getShapeTexture(style: IPointStyle): PIXI.Texture { const { radius, fill, stroke, strokeWidth, strokeOpacity } = style const key = this.textureKey(style) @@ -813,12 +911,25 @@ export class PixiPointRenderer extends PointRendererBase { return this.textures.get(key) as PIXI.Texture } + const shape = style.shape ?? kDefaultPointShape + const geometry = pointShapeGeometry(shape, radius) const graphics = new PIXI.Graphics() - .circle(0, 0, radius) + if (geometry.kind === "circle") { + graphics.circle(0, 0, geometry.radius) + } else { + graphics.poly(geometry.points.flatMap(({ x, y }) => [x, y])) + } + graphics .fill(fill) - .stroke({ color: stroke, width: strokeWidth, alpha: strokeOpacity ?? 0.4 }) - - return this.generateTexture(graphics, key) + // Rounded joins, matching the canvas renderer: PIXI would otherwise miter, which at the + // star's 36-degree tips reaches past the stroke padding and clips, and grows the spikes the + // canvas renderer already rounds away there and at the X's corners. + .stroke({ color: stroke, width: strokeWidth, alpha: strokeOpacity ?? 0.4, join: "round" }) + + // Circles keep the self-sizing path they have always used; everything else needs an explicit + // frame, for the reason given on symmetricFrame. + const frame = geometry.kind === "circle" ? undefined : symmetricFrame(shape, radius, strokeWidth) + return this.generateTexture(graphics, key, frame) } private getRectTexture(style: IPointStyle, includeDimensions = false): PIXI.Texture { @@ -849,13 +960,14 @@ export class PixiPointRenderer extends PointRendererBase { return this.generateTexture(graphics, key) } - private generateTexture(graphics: PIXI.Graphics, key: string): PIXI.Texture { + private generateTexture(graphics: PIXI.Graphics, key: string, frame?: PIXI.Rectangle): PIXI.Texture { if (!this.renderer) { throw new Error("PixiPointRenderer renderer not initialized") } const texture = this.renderer.generateTexture({ target: graphics, resolution: devicePixelRatio * MAX_SPRITE_SCALE, + ...(frame ? { frame } : {}) }) this.textures.set(key, texture) @@ -910,8 +1022,19 @@ export class PixiPointRenderer extends PointRendererBase { if (!isBar && !isPoint) return - const newWidth = isBar ? width - 1 : radius * 2 - const newHeight = isBar ? height - 1 : radius * 2 + /* + * Points animate to the size of the texture that is about to replace the bar's, rather than to + * 2r. The texture is the shape's symmetric extent plus its stroke -- about 2.7r wide for a star + * -- and it arrives at scale 1, so animating to 2r ends the transition with the sprite jumping + * to its real size. Circles were off by the stroke alone, which is why this went unnoticed. + */ + const destPointState = isPoint ? this.state.getPoint(pointId) : undefined + const destTexture = destPointState + ? this.getPointTexture({ ...destPointState.style, ...style }, true) + : undefined + + const newWidth = isBar ? width - 1 : destTexture?.width ?? radius * 2 + const newHeight = isBar ? height - 1 : destTexture?.height ?? radius * 2 const scaleXFactor = newWidth / sprite.width const scaleYFactor = newHeight / sprite.height @@ -927,6 +1050,8 @@ export class PixiPointRenderer extends PointRendererBase { if (pointState) { const newStyle = { ...pointState.style, ...style } const texture = this.getPointTexture(newStyle, true) + // the display type has changed by now, so what the sprite should be hit tested against has too + this.syncHitArea(sprite, newStyle) if (sprite.texture !== texture) { sprite.texture = texture diff --git a/v3/src/components/data-display/renderer/point-shapes.test.ts b/v3/src/components/data-display/renderer/point-shapes.test.ts index ab6569624b..5e60a276bc 100644 --- a/v3/src/components/data-display/renderer/point-shapes.test.ts +++ b/v3/src/components/data-display/renderer/point-shapes.test.ts @@ -1,7 +1,7 @@ import { PointShapes } from "../../../utilities/point-shape-utils" import { isPointInShape, pointShapeArea, pointShapeBoundingRadius, pointShapeExtent, - pointShapeGeometry + pointShapeGeometry, pointShapeSymmetricExtent } from "./point-shapes" import { Point } from "../data-display-types" @@ -200,6 +200,48 @@ describe("point shape geometry", () => { }) }) + describe("symmetric extent", () => { + it("contains the whole outline, centered on the point", () => { + PointShapes.filter(s => s !== "circle").forEach(shape => { + const geometry = pointShapeGeometry(shape, 8) + if (geometry.kind !== "polygon") throw new Error(`${shape} should be a polygon`) + const { w, h } = pointShapeSymmetricExtent(shape, 8) + geometry.points.forEach(({ x, y }) => { + expect(Math.abs(x)).toBeLessThanOrEqual(w / 2 + 1e-9) + expect(Math.abs(y)).toBeLessThanOrEqual(h / 2 + 1e-9) + }) + }) + }) + + it("is larger than the drawn box exactly where the box is off center", () => { + /* + * The triangle and the star hang off center because they are centered on their ink. A + * renderer positioning them by the middle of a box has to use this larger box, or it puts + * the middle of the drawn ink somewhere other than the point. + */ + const offCenter = ["triangle", "star"] as const + offCenter.forEach(shape => { + expect(pointShapeSymmetricExtent(shape, 8).h).toBeGreaterThan(pointShapeExtent(shape, 8).h) + }) + + PointShapes.filter(s => !offCenter.includes(s as any)).forEach(shape => { + const symmetric = pointShapeSymmetricExtent(shape, 8) + const drawn = pointShapeExtent(shape, 8) + expect(symmetric.w).toBeCloseTo(drawn.w, 6) + expect(symmetric.h).toBeCloseTo(drawn.h, 6) + }) + }) + + it("scales linearly with the radius", () => { + PointShapes.forEach(shape => { + const small = pointShapeSymmetricExtent(shape, 3) + const large = pointShapeSymmetricExtent(shape, 12) + expect(large.w / small.w).toBeCloseTo(4, 6) + expect(large.h / small.h).toBeCloseTo(4, 6) + }) + }) + }) + describe("containment", () => { it("puts the center of every shape inside it", () => { PointShapes.forEach(shape => expect(isPointInShape(shape, kR, 0, 0)).toBe(true)) diff --git a/v3/src/components/data-display/renderer/point-shapes.ts b/v3/src/components/data-display/renderer/point-shapes.ts index fba3bdc86a..aebe28f1f5 100644 --- a/v3/src/components/data-display/renderer/point-shapes.ts +++ b/v3/src/components/data-display/renderer/point-shapes.ts @@ -168,18 +168,41 @@ export function pointShapeArea(shape: PointShape, r: number): number { } /* - * The drawn bounding box. Used to size a shape against a box, and to check the normalization. + * The drawn bounding box. Used to check the tuning of the constants above, not to size anything -- + * for a triangle or a star this box is not centered on the point, so a renderer positioning a shape + * by the middle of a box wants pointShapeSymmetricExtent instead. */ export function pointShapeExtent(shape: PointShape, r: number): Extent { return kShapeDefs[shape].extent(r) } +/* + * The smallest box centered on the point that contains the drawn shape, which for a triangle or a + * star is larger than the box that hugs the ink. + * + * What a renderer needs when it positions a shape by the middle of a box -- drawing into a texture + * and anchoring it at 0.5, 0.5 does exactly that -- since the drawn box is not centered on the + * point it belongs to. + */ +export function pointShapeSymmetricExtent(shape: PointShape, r: number): Extent { + const geometry = pointShapeGeometry(shape, r) + if (geometry.kind === "circle") return { w: 2 * geometry.radius, h: 2 * geometry.radius } + + let maxAbsX = 0 + let maxAbsY = 0 + geometry.points.forEach(({ x, y }) => { + maxAbsX = Math.max(maxAbsX, Math.abs(x)) + maxAbsY = Math.max(maxAbsY, Math.abs(y)) + }) + return { w: 2 * maxAbsX, h: 2 * maxAbsY } +} + /* * The distance from the center to the furthest vertex, which is what hit testing needs. * - * Measured from the vertices rather than from the extent: the extent is the size of the drawn box, - * and a triangle's box is not centered on the point, so half its larger side stops short of the ink - * -- the apex sits at 2h/3 while half the width is s/2. A hit area sized that way misses the apex. + * Measured from the vertices rather than from the extent: a triangle's box is not centered on the + * point, so half its larger side stops short of the ink -- the apex sits at 2h/3 while half the + * width is s/2. A hit area sized that way misses the apex. */ export function pointShapeBoundingRadius(shape: PointShape, r: number): number { const geometry = pointShapeGeometry(shape, r) @@ -213,8 +236,20 @@ function isPointInPolygon(points: Point[], x: number, y: number): boolean { * matches the old circle wherever it is not. */ export function isPointInShape(shape: PointShape, r: number, dx: number, dy: number): boolean { + return isPointInShapeGeometry(pointShapeGeometry(shape, r), r, dx, dy) +} + +/* + * The same test against an outline the caller already has. + * + * For a caller that tests one point over and over, building the outline every time costs more than + * the test does -- a star's is ten sin/cos pairs and an allocation. Such a caller holds its geometry + * and comes here, so the containment itself is still written once. + */ +export function isPointInShapeGeometry( + geometry: PointShapeGeometry, r: number, dx: number, dy: number +): boolean { if (dx * dx + dy * dy <= r * r) return true - const geometry = pointShapeGeometry(shape, r) return geometry.kind === "circle" ? false : isPointInPolygon(geometry.points, dx, dy) }