thi-ng / umbrella

⛱ Broadly scoped ecosystem & mono-repository of 198 TypeScript projects (and ~175 examples) for general purpose, functional, data driven development
https://thi.ng
Apache License 2.0
3.31k stars 144 forks source link

[geom] Option to ignore scaling of stroke #420

Closed maxsei closed 9 months ago

maxsei commented 9 months ago

Currently, it does not seem possible to do have the stroke of the line ignored by the transform attribute in geom. Something like the following code does not seem possible without manually transforming points.

const cavasEl = document.getElementById("my-canvas");
let ctx = cavasEl.getContext("2d");
ctx.save();
ctx.scale(2, 0.5);
ctx.beginPath();
ctx.moveTo(10, 10);
ctx.lineTo(20, 30);
ctx.moveTo(20, 30);
ctx.lineTo(50, 40);
ctx.restore();
ctx.strokeStyle = `rgb(255, 0, 0)`
ctx.lineWidth = 2;
ctx.stroke();
postspectacular commented 9 months ago

Yes, the transform matrix of a shape will be active until the shape is completely drawn, same way as it is for SVG shapes. Your example is creating a manual intervention/separation between submitting transformed points to the canvas and then painting them untransformed... For hiccup-canvas to support drawing nested groups of shapes and each shape potentially temporarily overriding attribs of its parent, canvas state (incl. stroke/fill/lineWidth etc.) is being saved/restored before & after completely processing a shape. I'm not sure how this separation you're after could be supported without breaking existing behavior (nor would I consider this expected behavior either). Is it not possible for you to apply the inverse scaling factor to the weight attribute of the shape instead? I've never used non-uniform scaling of the canvas context, so I can't really tell how the canvas API handles those cases...

const scale = [2, 0.5];
const desiredLineWeight = 2;

polyline([[10,10], [20,30], [50,40]], { stroke: "red", scale, weight: desiredLineWeight/Math.max(...scale) })

Hope that explanation makes some sense. If not please let me know...

maxsei commented 9 months ago

Yes, I think that inverse scaling the weight of the line would be a reasonable assumption to make however in practice is inadequate.

In the below example I show three different values for the x component of the scaling vector. You can notice that as the scale of x increases the y component of the line weight becomes further from the desired weight.

import { polyline } from 'https://cdn.skypack.dev/@thi.ng/geom';
import { $canvas } from 'https://cdn.skypack.dev/@thi.ng/rdom-canvas';
import { $compile, $inputNum } from 'https://cdn.skypack.dev/@thi.ng/rdom';
import { reactive } from 'https://cdn.skypack.dev/@thi.ng/rstream';

const dims = [400, 100]
const desiredLineWeight = 5;
const scaleX = reactive(1)
const canvasBody = scaleX.map((scaleX)=> {
    const scale = [scaleX, 0.5];
    const weight = desiredLineWeight / Math.max(...scale)
    const ret = polyline([
        [10,  20],
        [20,  60],
        [50,  80],
        [50, 100],
        [60, 100],
    ], { __background: "#000", stroke: "red", scale, weight })
    return ret
})

const root = document.getElementById("root")
$compile([
    "div.w-100.h-100.pa4", {},
    $canvas(canvasBody, dims),
    ["label", {}, "scaleX"],
    ["input", { type: "number", oninput: $inputNum(scaleX), value: scaleX }],
]).mount(root)

Screenshot from 2023-10-19 17-32-15 Screenshot from 2023-10-19 17-32-31 Screenshot from 2023-10-19 17-55-08

postspectacular commented 9 months ago

Hmm... Shame, in that case I'm afraid you'll be left with creating pre-transformed geometry. But you don't have to do that for every single shape individually, just wrap all the ones which need that treatment and put them inside a group, then scale that one:

const a = group({}, [
  circle(50),
  polyline([[-100,0], [0,100], [100,100]])
])

// non-uniformly scale all of the group's children
const a2 = scale(a, [2, 0.5])

// original group as hiccup
a.toHiccup()
// [
//   'g',
//   {},
//   [ 'circle', undefined, [ 0, 0 ], 50 ],
//   [ 'polyline', { fill: 'none' }, [ [ -100, 0 ], [ 0, 100 ], [ 100, 100 ] ] ]
// ]

// transformed group as hiccup
// here you can see that due to non-uniform scaling the circle had to be converted 
// into an ellipse, some other shapes will be auto-converted too
a2.toHiccup()
// [
//   'g',
//   {},
//   [ 'ellipse', undefined, [ 0, 0 ], [ 100, 25 ] ],
//   [ 'polyline', { fill: 'none' }, [ [ -200, 0 ], [ 0, 50 ], [ 200, 50 ] ] ]
// ]

(Some shape types will be automatically converted to other types prior to transformation because they cannot be reliably represented in their original type anymore.)

Hope that helps! Aside from that, I'd still be interested in learning more about your usecase/reason for the non-uniform scaling is...

maxsei commented 9 months ago

My use case is displaying streamed sparse multivariate time series in an interactive canvas similar to this. However, I have found that most of the out-of-the-box solutions are not efficient enough to handle the throughput and volume.

I was hoping that the transforms could be done by the browser for performance reasons because to my knowledge pre-transforming geometry requires materializing transformed points into a JavaScript arrays (unless done in place). I've been pre-transforming the geometry already in my project but have recently switched to using scene graphs to make my code easier to maintain. Here is how I have extended the scenegraphs abstract node for working with pre-transformed geometry

class PreTxNode2D<T extends ISceneNode<any>> extends ANode<T> {
  constructor(
    id: string,
    parent: Nullable<T>,
    mat: Vec,
    body?: IShape<IShape<any>>
  ) {
    super(id, parent, body);
    this.mat = mat;
    this.update();
  }
  copy() {
    return new PreTxNode2D(this.id, this.parent, [...this.mat], this.body);
  }
  deleteChild(node: number | T): boolean {
    return this._deleteChild(node, PreTxNode2D);
  }
  update() {
    if (this.enabled) {
      if (this.parent) {
        mats.mulM23(this.mat, this.parent.mat, this.mat);
      }
      mats.invert23(this.invMat, this.mat);
      for (let c of this.children) {
        c.update();
      }
    }
  }
  mapGlobalPoint(p: ReadonlyVec): Vec | undefined {
    return mats.mulV23([], this.invMat, p);
  }
  mapLocalPointToGlobal(p: ReadonlyVec): Vec | undefined {
    return mats.mulV23([], this.mat, p);
  }
  mapLocalPointToNode(dest: T, p: ReadonlyVec): Vec | undefined {
    return mats.mulV23(null, dest.invMat, mats.mulV23([], this.mat, p));
  }
  toHiccup(ctx: unknown) {
    const body = isFunction(this.body) ? this.body(ctx) : deref(this.body);
    return this.enabled && this.display
      ? this.children.length
        ? [
            "g",
            {},
            this.body ? ["g", {}, g.transform(body, this.mat)] : undefined,
            ...this.children.map((c) => c.toHiccup(ctx)),
          ]
        : body
        ? ["g", {}, g.transform(body, this.mat)]
        : undefined
      : undefined;
  }
}
postspectacular commented 9 months ago

Hi @maxsei - will look at your latest code later, but just wanted to mention a (potential) alternative solution/consideration: The thi.ng/vectors package allows creating memory mapped views of arbitrary strided typed array buffers which could be used to store your data and update it in-place. You could then also use the thi.ng/simd package to perform your re-scaling via WASM, both of these features are meant (and have been used) for similar use cases... Just a rough example of the overall approach:

import { asSvg, polyline } from "@thi.ng/geom";
import { init } from "@thi.ng/simd";
import { Vec2 } from "@thi.ng/vectors";

const simd = await init(new WebAssembly.Memory({ initial: 1 }));

// buffer view of WASM memory region, sufficient for 4 vec2
// IMPORTANT: for SIMD the number of resulting coordinates MUST be a multiple of 4!
const buf = simd.f32.subarray(0, 4*2);
// set coordinates
buf.set([1,10, 2,20, 3,-30, 4,40])

// create vec2 views
// see docs for more options to control striding setup (AOS vs SOA etc.)
// https://docs.thi.ng/umbrella/vectors/classes/Vec2.html#mapBuffer
const points=Vec2.mapBuffer(buf);

// wrap as shape
const plot = polyline(points);

// inspect
asSvg(plot)
// <polyline fill="none" points="1,10 2,20 3,-30 4,40"/>

// create buffer for scaling vector
// SIMD is always using vec4, so we need to duplicate the values here
// (IMPORTANT: make sure memory region is not overlapping with your point data!)
const scale = simd.f32.subarray(16, 16+4);
scale.set([2,0.5, 2,0.5]);

// scale the points as WASM batch process, scaling 4 floats at once
simd.mul4_f32(
  // result address (here we want to modify in place)
  buf.byteOffset,
  // address of coordinates
  buf.byteOffset,
  // address of scaling vector
  scale.byteOffset,
  // number of vectors to process (as vec4!) 
  2,
  // output stride (in floats, i.e. 4 = 1x vec4 = densely packed, no gaps)
  4,
  // stride of coordinates
  4,
  // since we only have a single scaling vector,
  // we don't want progress the memory pointer for that one
  0
);

// verify the polyline has been updated...
asSvg(plot)
// <polyline fill="none" points="2,5 4,10 6,-15 8,20"/>
postspectacular commented 9 months ago

@maxsei Btw. If you go down the SIMD route and assuming the Y-coordinates (data points) are more frequently changing than the X-coordinates of your plot points (which might NOT be changing every frame), you can also arrange the data differently in memory, using an SOA (structure of arrays) layout: I.e. instead of storing point coordinates in interleaved X,Y,X,Y... order, you keep all the X's in one chunk of memory and the Y's in another. This then allows you to use the SIMD batch ops to only scale one or the other, but you'd have to change the Vec2.mapBuffer() config like so:

const num = 8;

const buf = simd.f32.subarray(0, num*2);
buf.set([
  // first all X coords
  1,2,3,4,5,6,7,8,
  // then all Y coords
  10,20,-30,40,-50,-60,70,80
])

const points=Vec2.mapBuffer(
  buf,
  // num points
  8,
  // start index
  0,
  // vector component stride (here `num` floats between the X/Y components of each vector)
  8,
  // element stride (successive vectors are next to each other in memory)
  1);

[...points[0]]
// [ 1, 10 ]

[...points[1]]
// [ 2, 20 ]

asSvg(polyline(points))
// <polyline fill="none" points="1,10 2,20 3,-30 4,40 5,-50 6,-60 7,70 8,80"/>

Btw. Alternatively, there're another 2 dedicated packages for this (not saying you should use those instead, but might be helpful for related aspects): https://thi.ng/soa, https://thi.ng/vector-pools

maxsei commented 9 months ago

@postspectacular the simd approach seems reasonable to me. Here is my attempt at using simd alongside the geom package's transform.

I'm not entirely sure why the simd transformed values are not displaying properly despite the values being approximately identical to the geom.transform'd values. I also might be misunderstanding the memory management as well because occasionally the end of the transformed buffer appears to be uninitialized.

I've included the critical section of the code if you could point out any inconsistent usages of the simd api

  const [_, series] = tx.pairs(features).next().value; // series is an Array of [number, number]

  // SIMD Implementation
  const buf = simd.f32.subarray(0, series.length * 2);
  buf.set([...tx.flatten1(series)]);
  const points = Vec2.mapBuffer(buf);
  const lineSimd = g.polyline(points, { stroke: "#fff" });
  const src = g.bounds(lineSimd)!;
  const dst = g.rect([0, 0], dims);
  const tsl = translation23([], sub2([], dst.pos, src.pos));
  const scl = scaleWithCenter23([], dst.pos, safeDiv2([], dst.size, src.size));
  const tfm = mulM23([], scl, tsl);
  const tfmBuf = simd.f32.subarray(points.length + 8, points.length + 8 + tfm.length);
  tfmBuf.set(tfm);
  simd.mul_m23v2_aos(buf.byteOffset, tfmBuf.byteOffset, buf.byteOffset, series.length);

  // Geom Implementation
  {
    const linePre = g.polyline(series, { stroke: "#fff" });
    const src = g.bounds(linePre)!;
    const dst = g.rect([0, 0], dims);
    const tsl = translation23([], sub2([], dst.pos, src.pos));
    const scl = scaleWithCenter23([], dst.pos, safeDiv2([], dst.size, src.size));
    const tfm = mulM23([], scl, tsl);
    const lineGeom = g.transform(linePre, tfm);
    lineSimd;
    debugger;
  }

image image

postspectacular commented 9 months ago

Not having any of your test data and having updated your code to use yet another thi.ng package to help you with the manual memory management, I'm getting identical results for both cases:

Complete example:

import { polyline, bounds, rect, transform, asSvg } from "@thi.ng/geom";
import { translation23, scaleWithCenter23, mulM23 } from "@thi.ng/matrices";
import { MemPool } from "@thi.ng/malloc";
import { init } from "@thi.ng/simd";
import { flatten1 } from "@thi.ng/transducers";
import { Vec2, safeDiv2, sub2 } from "@thi.ng/vectors";

const dims = [640, 480];
const simd = init(new WebAssembly.Memory({ initial: 1 }))!;
// use memory allocator/manager to avoid having to deal with address juggling
const pool = new MemPool({
    // use the WASM memory as backing buffer
    buf: simd.memory.buffer,
    // must be 16 for SIMD purposes (i.e. vec4 = 16 bytes)
    align: 16,
});

const series = [
    [1, 10],
    [2, 20],
    [3, -30],
    [4, -40],
    [5, 50],
    [6, -66],
    [7, 70],
    [8, 80],
];

// SIMD Implementation

// allocate buffer via managed mem pool
const buf = pool.mallocAs("f32", series.length * 2)!;
buf.set([...flatten1<number>(series)]);

const points = Vec2.mapBuffer(buf);
const lineSimd = polyline(points, { stroke: "#fff" });
const src = bounds(lineSimd)!;
const dst = rect([0, 0], dims);
const tsl = translation23([], sub2([], dst.pos, src.pos));
const scl = scaleWithCenter23([], dst.pos, safeDiv2([], dst.size, src.size));
// allocate buffer via managed mem pool
const tfm = pool.mallocAs("f32", tsl.length)!;
// write result matrix directly into tfm
mulM23(tfm, scl, tsl);

simd.mul_m23v2_aos(
    buf.byteOffset,
    tfm.byteOffset,
    buf.byteOffset,
    series.length
);

{
    // Geom Implementation
    const linePre = polyline(series, { stroke: "#fff" });
    const src = bounds(linePre)!;
    const dst = rect([0, 0], dims);
    const tsl = translation23([], sub2([], dst.pos, src.pos));
    const scl = scaleWithCenter23(
        [],
        dst.pos,
        safeDiv2([], dst.size, src.size)
    );
    const tfm = mulM23([], scl, tsl);
    const lineGeom = transform(linePre, tfm);

    console.log("simd", asSvg(lineSimd));
    console.log("geom", asSvg(lineGeom));
}

// house keeping, free allocated memory after use (just like in C)
// (see https://thi.ng/malloc readme for details)
pool.free(buf);
pool.free(tfm);

Output:

simd <polyline fill="none" points="0,249.863 91.429,282.740 182.857,118.356 274.286,85.479 365.714,381.370 457.143,-0.000 548.571,447.123 640,480" stroke="#fff"/>
geom <polyline fill="none" points="0,249.863 91.429,282.740 182.857,118.356 274.286,85.479 365.714,381.370 457.143,0 548.571,447.123 640,480" stroke="#fff"/>

The only difference is that that negative-zero coordinate in the 3rd last point. This is due to SIMD only using f32 math (vs. f64 for normal JS numbers), but that's really negligible...

maxsei commented 9 months ago

@postspectacular After further inspection of my data (which I apologize for not including directly, but is generated from this linked gist), there does appear to be a loss of precision when transforming the data because the Y's are millisecond timestamps.

Despite this issue going somewhat off-topic, I hope that others might read this discourse and like I did, learn the distinction between transforming point geometry and using options provided for actually rendering the geometry (line width, stroke, etc).

I really appreciate your feedback and I find this package ecosystem a joy to use amongst the swath of complicated tools on the web

postspectacular commented 9 months ago

@maxsei Thank you so much for the kind words! I too enjoyed that issue very much, since it finally also triggered me to deal with some of these related issues. I ended up refactoring the example into a new #HowToThing example, incl. adding some new small features for the hiccup-canvas and matrices packages...

Ps. I might move this issue over to discussions, since it might be of interest to more people...

Pps. May I use that last sentence of your last reply as a quote on the thi.ng website (at some point in the near future)? Let me know please...

maxsei commented 9 months ago

@postspectacular I'm stoked on the example and the new features, especially fit23. Also, feel welcome to use my words that is fine by me

postspectacular commented 9 months ago

Thank you very much! And yes, fit23() is/will be useful! I extracted it from related functionality (fitIntoBounds2() in thi.ng/geom here):

https://github.com/thi-ng/umbrella/blob/dc7eeaba72f3c3977f2d6cc201e595ed6d68e2ad/packages/geom/src/fit-into-bounds.ts#L21-L57