Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
28 changes: 28 additions & 0 deletions src/group.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,13 @@ export class Group extends Shape {
*/
_mask = null;

/**
* @name Two.Group#_strokeAttenuation
* @private
* @see {@link Two.Group#strokeAttenuation}
*/
_strokeAttenuation = true;

constructor(children) {
super();

Expand Down Expand Up @@ -1061,6 +1068,27 @@ const proto = {
}
},
},

/**
* @name Two.Group#strokeAttenuation
* @property {Boolean} - When set to `true`, stroke width scales with transformations (default behavior). When `false`, stroke width remains constant in screen space for all child shapes.
* @description When `strokeAttenuation` is `false`, this property is applied to all child shapes, making their stroke widths automatically adjust to compensate for the group's world transform scale, maintaining constant visual thickness regardless of zoom level. When `true` (default), stroke widths scale normally with transformations.
*/
strokeAttenuation: {
enumerable: true,
get: function () {
return this._strokeAttenuation;
},
set: function (v) {
this._strokeAttenuation = !!v;
for (let i = 0; i < this.children.length; i++) {
const child = this.children[i];
if (child.strokeAttenuation !== undefined) {
child.strokeAttenuation = v;
}
Comment on lines +1107 to +1111
Copy link

Copilot AI Sep 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setter iterates through all children every time the property is set, which could be inefficient for groups with many children. Consider implementing lazy propagation or only updating children that have strokeAttenuation property.

Suggested change
for (let i = 0; i < this.children.length; i++) {
const child = this.children[i];
if (child.strokeAttenuation !== undefined) {
child.strokeAttenuation = v;
}
// Only update children that have a defined strokeAttenuation property
const attenuableChildren = this.children.filter(child => child && child.strokeAttenuation !== undefined);
for (let i = 0; i < attenuableChildren.length; i++) {
attenuableChildren[i].strokeAttenuation = v;

Copilot uses AI. Check for mistakes.
}
},
},
};

// /**
Expand Down
33 changes: 33 additions & 0 deletions src/path.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ export class Path extends Shape {
*/
_flagMiter = true;

/**
* @name Two.Path#_flagStrokeAttenuation
* @private
* @property {Boolean} - Determines whether the {@link Two.Path#strokeAttenuation} needs updating.
*/
_flagStrokeAttenuation = true;

/**
* @name Two.Path#_flagMask
* @private
Expand Down Expand Up @@ -250,6 +257,13 @@ export class Path extends Shape {
*/
_dashes = null;

/**
* @name Two.Path#_strokeAttenuation
* @private
* @see {@link Two.Path#strokeAttenuation}
*/
_strokeAttenuation = true;

constructor(vertices, closed, curved, manual) {
super();

Expand Down Expand Up @@ -407,6 +421,7 @@ export class Path extends Shape {
'beginning',
'ending',
'dashes',
'strokeAttenuation',
];

static Utils = {
Expand Down Expand Up @@ -1222,6 +1237,7 @@ export class Path extends Shape {
this._flagJoin =
this._flagMiter =
this._flagClip =
this._flagStrokeAttenuation =
false;

Shape.prototype.flagReset.call(this);
Expand Down Expand Up @@ -1507,6 +1523,23 @@ const proto = {
this._dashes = v;
},
},

/**
* @name Two.Path#strokeAttenuation
* @property {Boolean} - When set to `true`, stroke width scales with transformations (default behavior). When `false`, stroke width remains constant in screen space.
* @description When `strokeAttenuation` is `false`, the stroke width is automatically adjusted to compensate for the object's world transform scale, maintaining constant visual thickness regardless of zoom level. When `true` (default), stroke width scales normally with transformations.
*/
strokeAttenuation: {
enumerable: true,
get: function () {
return this._strokeAttenuation;
},
set: function (v) {
this._strokeAttenuation = !!v;
this._flagStrokeAttenuation = true;
this._flagLinewidth = true;
},
},
};

// Utility functions
Expand Down
8 changes: 4 additions & 4 deletions src/renderers/canvas.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Commands } from '../utils/path-commands.js';
import { decomposeMatrix, mod, TWO_PI } from '../utils/math.js';
import { decomposeMatrix, mod, TWO_PI, getEffectiveStrokeWidth } from '../utils/math.js';
import { Curve } from '../utils/curves.js';
import { Events } from '../events.js';
import { getRatio } from '../utils/device-pixel-ratio.js';
Expand Down Expand Up @@ -232,7 +232,7 @@ const canvas = {
ctx.strokeStyle = stroke._renderer.effect;
}
if (linewidth) {
ctx.lineWidth = linewidth;
ctx.lineWidth = getEffectiveStrokeWidth(this);
}
if (miter) {
ctx.miterLimit = miter;
Expand Down Expand Up @@ -490,7 +490,7 @@ const canvas = {
ctx.strokeStyle = stroke._renderer.effect;
}
if (linewidth) {
ctx.lineWidth = linewidth;
ctx.lineWidth = getEffectiveStrokeWidth(this);
}
}
if (typeof opacity === 'number') {
Expand Down Expand Up @@ -664,7 +664,7 @@ const canvas = {
ctx.strokeStyle = stroke._renderer.effect;
}
if (linewidth) {
ctx.lineWidth = linewidth;
ctx.lineWidth = getEffectiveStrokeWidth(this);
}
}
if (typeof opacity === 'number') {
Expand Down
8 changes: 4 additions & 4 deletions src/renderers/svg.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Commands } from '../utils/path-commands.js';
import { decomposeMatrix, mod, toFixed } from '../utils/math.js';
import { decomposeMatrix, mod, toFixed, getEffectiveStrokeWidth } from '../utils/math.js';
import { Events } from '../events.js';
import { _ } from '../utils/underscore.js';

Expand Down Expand Up @@ -505,7 +505,7 @@ const svg = {
}

if (this._flagLinewidth) {
changed['stroke-width'] = this._linewidth;
changed['stroke-width'] = getEffectiveStrokeWidth(this);
}

if (this._flagOpacity) {
Expand Down Expand Up @@ -672,7 +672,7 @@ const svg = {
}

if (this._flagLinewidth) {
changed['stroke-width'] = this._linewidth;
changed['stroke-width'] = getEffectiveStrokeWidth(this);
}

if (this._flagOpacity) {
Expand Down Expand Up @@ -797,7 +797,7 @@ const svg = {
}
}
if (this._flagLinewidth) {
changed['stroke-width'] = this._linewidth;
changed['stroke-width'] = getEffectiveStrokeWidth(this);
}
if (this._flagOpacity) {
changed.opacity = this._opacity;
Expand Down
8 changes: 4 additions & 4 deletions src/renderers/webgl.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Commands } from '../utils/path-commands.js';

import { root } from '../utils/root.js';
import { getPoT, mod, NumArray, TWO_PI } from '../utils/math.js';
import { getPoT, mod, NumArray, TWO_PI, getEffectiveStrokeWidth } from '../utils/math.js';
import { shaders } from '../utils/shaders.js';
import { Events } from '../events.js';
import { TwoError } from '../utils/error.js';
Expand Down Expand Up @@ -235,7 +235,7 @@ const webgl = {
ctx.strokeStyle = stroke._renderer.effect;
}
if (linewidth) {
ctx.lineWidth = linewidth;
ctx.lineWidth = getEffectiveStrokeWidth(elem);
}
if (miter) {
ctx.miterLimit = miter;
Expand Down Expand Up @@ -731,7 +731,7 @@ const webgl = {
ctx.strokeStyle = stroke._renderer.effect;
}
if (linewidth) {
ctx.lineWidth = linewidth / aspect;
ctx.lineWidth = getEffectiveStrokeWidth(elem) / aspect;
}
}
if (typeof opacity === 'number') {
Expand Down Expand Up @@ -1039,7 +1039,7 @@ const webgl = {
ctx.strokeStyle = stroke._renderer.effect;
}
if (linewidth) {
ctx.lineWidth = linewidth;
ctx.lineWidth = getEffectiveStrokeWidth(elem);
}
}
if (typeof opacity === 'number') {
Expand Down
40 changes: 39 additions & 1 deletion src/utils/math.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,46 @@ function toFixed(v) {
return floor(v * 1000000) / 1000000;
}

/**
* @name Two.Utils.getEffectiveStrokeWidth
* @function
* @param {Two.Path|Two.Group} object - The object to calculate effective stroke width for
* @param {Two.Matrix} [worldMatrix] - The world transformation matrix. If not provided, will be calculated.
* @returns {Number} The effective stroke width adjusted for strokeAttenuation setting
* @description Calculate effective stroke width, compensating for world scale if strokeAttenuation is false
*/
function getEffectiveStrokeWidth(object, worldMatrix) {
const linewidth = object._linewidth;

// If strokeAttenuation is true (default), return original linewidth (scales with transforms)
if (object.strokeAttenuation) {
return linewidth;
}

// Calculate world matrix if not provided
if (!worldMatrix) {
worldMatrix = object.worldMatrix || getComputedMatrix(object);
}

// Decompose matrix to get scale
const decomposed = decomposeMatrix(
worldMatrix.elements[0],
worldMatrix.elements[3],
worldMatrix.elements[1],
worldMatrix.elements[4],
worldMatrix.elements[2],
worldMatrix.elements[5]
);

// Use the larger of the two scale factors to maintain uniform appearance
const scale = Math.max(Math.abs(decomposed.scaleX), Math.abs(decomposed.scaleY));

// Compensate for scale to maintain constant screen-space width
return scale > 0 ? linewidth / scale : linewidth;
}


export {
decomposeMatrix, getComputedMatrix, getPoT, setMatrix, lerp, mod, NumArray,
toFixed, TWO_PI, HALF_PI
toFixed, getEffectiveStrokeWidth, TWO_PI, HALF_PI
};
29 changes: 29 additions & 0 deletions types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,19 @@ declare module 'two.js/src/utils/math' {
export function toFixed(v: number): number;
export const TWO_PI: number;
export const HALF_PI: number;
/**
* @name Two.Utils.getEffectiveStrokeWidth
* @function
* @param {Path|Group} object - The object to calculate effective stroke width for
* @param {Matrix} [worldMatrix] - The world transformation matrix. If not provided, will be calculated.
* @returns {Number} The effective stroke width adjusted for strokeAttenuation setting
* @description Calculate effective stroke width, compensating for world scale if strokeAttenuation is false
*/
export function getEffectiveStrokeWidth(object: Path | Group, worldMatrix?: Matrix): number;
import { Matrix } from 'two.js/src/matrix';
import { Shape } from 'two.js/src/shape';
import { Path } from 'two.js/src/path';
import { Group } from 'two.js/src/group';
}
declare module 'two.js/src/events' {
/**
Expand Down Expand Up @@ -1600,6 +1611,12 @@ declare module 'two.js/src/group' {
* @property {Shape} - The Two.js object to clip from a group's rendering.
*/
mask: Shape | undefined;
/**
* @name Two.Group#strokeAttenuation
* @property {Boolean} - When set to `true`, stroke width scales with transformations (default behavior). When `false`, stroke width remains constant in screen space for all child shapes.
* @description When `strokeAttenuation` is `false`, this property is applied to all child shapes, making their stroke widths automatically adjust to compensate for the group's world transform scale, maintaining constant visual thickness regardless of zoom level. When `true` (default), stroke widths scale normally with transformations.
*/
strokeAttenuation: boolean;
/**
* @name Two.Group#additions
* @property {Shape[]}
Expand Down Expand Up @@ -2829,6 +2846,12 @@ declare module 'two.js/src/path' {
* @see {@link Two.Path#dashes}
*/
private _dashes;
/**
* @name Two.Path#_strokeAttenuation
* @private
* @see {@link Two.Path#strokeAttenuation}
*/
private _strokeAttenuation;
/**
* @name Two.Path#closed
* @property {Boolean} - Determines whether a final line is drawn between the final point in the `vertices` array and the first point.
Expand Down Expand Up @@ -2928,6 +2951,12 @@ declare module 'two.js/src/path' {
dashes: number[] & {
offset?: number;
};
/**
* @name Two.Path#strokeAttenuation
* @property {Boolean} - When set to `true`, stroke width scales with transformations (default behavior). When `false`, stroke width remains constant in screen space.
* @description When `strokeAttenuation` is `false`, the stroke width is automatically adjusted to compensate for the object's world transform scale, maintaining constant visual thickness regardless of zoom level. When `true` (default), stroke width scales normally with transformations.
*/
strokeAttenuation: boolean;
/**
* @name Two.Path#copy
* @function
Expand Down
Loading