A transform-preserving mathematical layer for WYSIWYG manipulation of graphic element trees
When building WYSIWYG graphic editors, you typically face a fundamental challenge: How do you allow users to resize and move elements visually while maintaining their transformation properties?
Most graphic systems handle resizing by modifying the element's transform matrix (scale, skew, rotation). This works, but it creates a problem: the original transformation intent gets lost. If an element was deliberately rotated 45° and then resized, should that rotation be preserved exactly, or should it change?
bbox-skeleton takes a different approach: When you manipulate elements in world space (dragging corners, resizing), the system calculates what changes are needed to the underlying geometry (x, y, width, height) while keeping the transform properties (scale, skew, rotation, transform-origin) completely intact.
This is particularly valuable when:
- Building design tools where transform properties have semantic meaning
- Working with animation systems where transforms are keyframed
- Integrating with existing graphic engines that treat transforms as first-class properties
- Creating parametric or expression-based graphic systems
A skeleton is the mathematical representation of an element's shape in space — specifically, its four corner points:
[top-left, top-right, bottom-right, bottom-left]
Every element in bbox-skeleton has multiple skeleton representations:
- Base Skeleton: The untransformed bounding box corners (pure geometry)
- Element Skeleton: Base skeleton with local transforms applied
- World Skeleton: Element skeleton transformed through all ancestor transforms
When you drag a corner in a WYSIWYG editor, you're manipulating the world skeleton. bbox-skeleton's job is to project that manipulation back down to changes in the base geometry (x, y, width, height of shapes) while preserving all transform matrices in the tree.
- 🎯 Transform-Preserving Geometry Updates — Manipulations update geometry, not transforms
- 🌳 Hierarchical Element Trees — Full support for nested groups with inherited transforms
- 🎨 Engine-Agnostic Design — Abstract mathematical layer that adapts to any graphic engine
- 📐 Transform Origin Management — Sophisticated handling of transform origins with compensation
- 🔄 Skeleton Projection — Project world-space manipulations to local-space geometry changes
- 🎛️ Corner & Edge Resizing — Drag from any corner or edge, with optional aspect ratio locking
- 📦 Type-Safe & Fully Typed — Written in TypeScript with comprehensive type definitions
npm install bbox-skeleton transformation-matrixbbox-skeleton depends on transformation-matrix for matrix operations.
import {
SimpleElementShape,
SimpleElementGroup,
getElementWorldSkeleton,
applySkeletonInPlace,
getElementWorldMatrix
} from 'bbox-skeleton';
import { identity } from 'transformation-matrix';
// Define a simple shape
const rectangle: SimpleElementShape = {
type: 'shape',
x: 100,
y: 100,
width: 200,
height: 100,
coreTransform: identity(), // No transformation
transformOrigin: { x: 150, y: 150 }, // Center of shape
meta: { id: 'rect1' }
};
// Get its world skeleton (4 corner points)
const worldSkeleton = getElementWorldSkeleton([rectangle], rectangle);
// => [{x: 100, y: 100}, {x: 300, y: 100}, {x: 300, y: 200}, {x: 100, y: 200}]
// Simulate dragging bottom-right corner to new position
const newWorldSkeleton = [
{ x: 100, y: 100 },
{ x: 400, y: 100 }, // Expanded right
{ x: 400, y: 250 }, // Expanded down
{ x: 100, y: 250 }
];
// Project this change back to geometry updates
const worldMatrix = getElementWorldMatrix([rectangle], rectangle);
const changes = applySkeletonInPlace(rectangle, newWorldSkeleton, worldMatrix);
// Apply changes
for (const { el, newLocalBBox } of changes) {
el.x = newLocalBBox.x;
el.y = newLocalBBox.y;
el.width = newLocalBBox.width;
el.height = newLocalBBox.height;
}
// Result: rectangle.width is now 300, rectangle.height is now 150
// The coreTransform remains identity() — unchanged!The core abstraction is SimpleElement, which can be either a SimpleElementShape or a SimpleElementGroup:
type SimpleElement<Meta extends SimpleElementMeta = {}> =
| SimpleElementShape<Meta>
| SimpleElementGroup<Meta>;A leaf node representing a drawable element:
type SimpleElementShape<Meta = {}> = {
type: 'shape';
x: number; // Local x position
y: number; // Local y position
width: number; // Local width
height: number; // Local height
coreTransform: Matrix; // The transformation matrix (scale, rotate, skew)
transformOrigin: PointObjectNotation; // Absolute transform origin point
meta: Meta; // Your custom metadata (element ID, etc.)
};A container node that groups children:
type SimpleElementGroup<Meta = {}> = {
type: 'group';
children: SimpleElement<Meta>[];
coreTransform: Matrix;
transformOrigin: PointObjectNotation;
meta: Meta;
};Important: A group's bounding box is computed from its children. It has no explicit x, y, width, height properties.
The meta field is your connection point to your actual graphic engine:
type MyElementMeta = {
engineElement: MyGraphicEngineElement;
id: string;
customData: any;
};
type MyElement = SimpleElement<MyElementMeta>;This keeps bbox-skeleton decoupled from your specific implementation while maintaining full type safety.
Recursively iterates over all elements in a tree, including nested groups:
const allElements = Array.from(collectAllElements(rootElements));
for (const element of collectAllElements(rootElements)) {
console.log(element.meta.id);
}Finds the first element matching a predicate:
const targetElement = findElement(rootElements,
el => el.meta.id === 'my-element-id'
);Returns the local bounding box without any transformations:
const localBBox = getElementLocalBBox(element);
// => { x: 100, y: 50, width: 200, height: 150 }For shapes, this is simply { x, y, width, height }. For groups, it's computed from all children's skeletons.
Returns the four corner points of the local bounding box:
const baseSkeleton = getElementBaseSkeleton(element);
// => [
// { x: 100, y: 50 }, // top-left
// { x: 300, y: 50 }, // top-right
// { x: 300, y: 200 }, // bottom-right
// { x: 100, y: 200 } // bottom-left
// ]Returns the skeleton transformed by the element's local transformation matrix:
const elementSkeleton = getElementSkeleton(element);
// If element has rotation/skew, these corners will be transformed accordinglyReturns the skeleton in world coordinates, with all ancestor transformations applied:
const worldSkeleton = getElementWorldSkeleton(rootElements, myElement);
// This is what the user sees in the canvasThis is the most important function for WYSIWYG editors — it tells you where to draw the interactive handles.
Combines the element's coreTransform with its transformOrigin:
const matrix = getBakedTransformMatrix(element);
// Equivalent to: translate(origin) -> coreTransform -> translate(-origin)The "baking" process accounts for the transform origin. In matrix terms:
M_baked = T(origin) · M_core · T(-origin)
Computes the combined transformation matrix from root to element:
const worldMatrix = getElementWorldMatrix(rootElements, myElement);
// Composition of all ancestor transforms + own transformapplySkeletonInPlace<Meta>(el: SimpleElement<Meta>, newSkeleton: Skeleton, skeletonMatrix: Matrix): ElementChangeRecord<Meta>[]
The heart of bbox-skeleton. Projects a new skeleton back to geometry changes:
const changes = applySkeletonInPlace(element, newWorldSkeleton, worldMatrix);
for (const { el, newLocalBBox } of changes) {
el.x = newLocalBBox.x;
el.y = newLocalBBox.y;
el.width = newLocalBBox.width;
el.height = newLocalBBox.height;
}Understanding the skeletonMatrix parameter:
This is conceptually crucial: The skeletonMatrix defines in which coordinate space the newSkeleton is expressed.
- When called from outside (interactive editing): This is typically the element's world matrix, because the user manipulated the skeleton in world space (on the canvas)
- During recursion (inside groups): This becomes the child's local matrix in the context of its parent group
The function transforms the skeleton from whatever space it's in (via the inverse of skeletonMatrix) into the element's local coordinate space to compute geometry changes.
How it works:
- Inverts the
skeletonMatrixto transform the new skeleton into the element's local coordinate space - For shapes: Computes the new local bounding box directly
- For groups:
- Converts the new skeleton to a local bounding box
- For each child, computes how its skeleton should change using relative coordinates
- Recursively calls
applySkeletonInPlaceon each child with the child's own local matrix as theskeletonMatrix
- Returns a flat array of all shape changes in the entire subtree
Key insight: The recursive nature means that when you resize a group, the function "flows down" the transformation through the hierarchy. Each level converts world-space changes to its local space, then propagates proportional changes to children in their local spaces. This preserves relative positioning and all transform properties throughout the tree.
Computes the axis-aligned bounding box that contains a skeleton:
const bbox = calcBBoxFromSkeleton(skeleton);
// Useful for computing group bounding boxesMoves a skeleton by a vector:
const movedSkeleton = translateSkeleton(skeleton, { x: 50, y: 30 });Transform origins are tricky. When you change an element's transform origin, the visual position of the element changes unless you compensate by adjusting the geometry.
adjustLocalBBoxForNewTransformOrigin<Meta>(element: SimpleElement<Meta>, newOriginAbs: PointObjectNotation)
Changes the transform origin while keeping the element visually in the same place:
const { changes, transformOriginAbs } = adjustLocalBBoxForNewTransformOrigin(
element,
{ x: 200, y: 150 } // New absolute transform origin
);
// Update element
element.transformOrigin = transformOriginAbs;
// Apply geometry changes
for (const { el, newLocalBBox } of changes) {
el.x = newLocalBBox.x;
el.y = newLocalBBox.y;
el.width = newLocalBBox.width;
el.height = newLocalBBox.height;
}The math: When changing transform origin from o_old to o_new, the geometry must shift by a compensation delta:
δ = (M_core⁻¹ - I) · (o_old - o_new)
This ensures the visual result remains unchanged.
adjustLocalBBoxForNewTransformOriginRelative<Meta>(element: SimpleElement<Meta>, relativeOrigin: [number, number])
Same as above, but with relative coordinates (0-1 range within bounding box):
const { changes, transformOriginAbs } = adjustLocalBBoxForNewTransformOriginRelative(
element,
[0.5, 0.5] // Center of element
);Relative origins are often more intuitive:
[0, 0]= top-left corner[0.5, 0.5]= center[1, 1]= bottom-right corner
These functions handle interactive resizing from corners and edges:
Projects a corner handle drag to a new skeleton:
import { projectSkeletonFromCorner } from 'bbox-skeleton';
const { skeleton: newSkeleton } = projectSkeletonFromCorner(
worldSkeletonAtDragStart,
worldMatrix,
2, // bottom-right corner
{ x: mouseX, y: mouseY },
null // or pass aspectRatio to lock proportions
);Corner handle indices:
0 ●━━━━━━━━━━━● 1
┃ ┃
┃ ┃
┃ ┃
3 ●━━━━━━━━━━━● 2
0 = top-left
1 = top-right
2 = bottom-right
3 = bottom-left
The indices follow a clockwise pattern starting from top-left. When you drag a corner handle, the opposite corner remains fixed as the anchor point.
Aspect ratio locking: Pass the original aspect ratio to maintain proportions during resize (useful for Shift-key behavior).
Projects an edge handle drag to a new skeleton:
import { projectSkeletonFromEdge } from 'bbox-skeleton';
const { skeleton: newSkeleton } = projectSkeletonFromEdge(
worldSkeletonAtDragStart,
worldMatrix,
1, // right edge
{ x: mouseX, y: mouseY },
null
);Edge handle indices:
╔═══ 0 ═══╗
║ ║
3 ║ ║ 1
║ ║
╚═══ 2 ═══╝
0 = top edge
1 = right edge
2 = bottom edge
3 = left edge
The indices follow a clockwise pattern starting from top. Edge resizing moves one edge while keeping the opposite edge fixed. When aspect ratio is locked, the perpendicular dimension adjusts to maintain proportions.
Calculates the aspect ratio of a skeleton in normalized space:
const aspectRatio = getAspectRatioOfSkeleton(skeleton, worldMatrix);
// Use this value for aspect-ratio-locked resizingHere's how you'd integrate bbox-skeleton into a graphic editor:
import {
SimpleElement,
getElementWorldSkeleton,
getElementWorldMatrix,
applySkeletonInPlace,
projectSkeletonFromCorner,
getAspectRatioOfSkeleton
} from 'bbox-skeleton';
class GraphicEditor {
elements: SimpleElement[] = [];
selectedElement: SimpleElement | null = null;
dragState: {
skeletonAtStart: Skeleton;
worldMatrix: Matrix;
handleIndex: number;
aspectRatio: number;
} | null = null;
onHandleMouseDown(element: SimpleElement, handleIndex: number) {
this.selectedElement = element;
const worldSkeleton = getElementWorldSkeleton(this.elements, element);
const worldMatrix = getElementWorldMatrix(this.elements, element);
this.dragState = {
skeletonAtStart: worldSkeleton,
worldMatrix,
handleIndex,
aspectRatio: getAspectRatioOfSkeleton(worldSkeleton, worldMatrix)
};
}
onMouseMove(mouseX: number, mouseY: number, shiftKeyPressed: boolean) {
if (!this.dragState || !this.selectedElement) return;
const { skeletonAtStart, worldMatrix, handleIndex, aspectRatio } = this.dragState;
// Project the new handle position to a new skeleton
const { skeleton: newWorldSkeleton } = projectSkeletonFromCorner(
skeletonAtStart,
worldMatrix,
handleIndex,
{ x: mouseX, y: mouseY },
shiftKeyPressed ? aspectRatio : null // Lock aspect ratio if shift pressed
);
// Calculate geometry changes
const changes = applySkeletonInPlace(
this.selectedElement,
newWorldSkeleton,
worldMatrix
);
// Apply changes to your graphic engine
for (const { el, newLocalBBox } of changes) {
this.updateEngineElement(el.meta.engineElement, newLocalBBox);
// Also update the abstract model
if (el.type === 'shape') {
el.x = newLocalBBox.x;
el.y = newLocalBBox.y;
el.width = newLocalBBox.width;
el.height = newLocalBBox.height;
}
}
this.render();
}
onMouseUp() {
this.dragState = null;
}
updateEngineElement(engineElement: any, bbox: ElementBBox) {
// Update your actual graphic engine here
// This is where you'd call your engine's specific API
engineElement.setPosition(bbox.x, bbox.y);
engineElement.setSize(bbox.width, bbox.height);
}
}bbox-skeleton is designed to be engine-agnostic. Here's the adapter pattern:
// 1. Define your meta type
type MyEngineMeta = {
engineElement: MyEngineElement;
id: string;
};
// 2. Create conversion functions
function convertToSimpleElement(engineElement: MyEngineElement): SimpleElement<MyEngineMeta> {
return {
type: 'shape',
x: engineElement.x,
y: engineElement.y,
width: engineElement.width,
height: engineElement.height,
coreTransform: engineElement.getTransformMatrix(),
transformOrigin: engineElement.getTransformOrigin(),
meta: {
engineElement,
id: engineElement.id
}
};
}
function applyChangesToEngine(
changeRecords: ElementChangeRecord<MyEngineMeta>[]
) {
for (const { el, newLocalBBox } of changeRecords) {
const engineElement = el.meta.engineElement;
// Update your engine
engineElement.setBounds(
newLocalBBox.x,
newLocalBBox.y,
newLocalBBox.width,
newLocalBBox.height
);
// Keep the abstract model in sync
el.x = newLocalBBox.x;
el.y = newLocalBBox.y;
el.width = newLocalBBox.width;
el.height = newLocalBBox.height;
}
}
// 3. Build your tree
const simpleElements = myEngineElements.map(convertToSimpleElement);
// 4. Use bbox-skeleton
const worldSkeleton = getElementWorldSkeleton(simpleElements, targetElement);
// ... manipulation logic ...
const changes = applySkeletonInPlace(element, newSkeleton, worldMatrix);
// 5. Apply back to engine
applyChangesToEngine(changes);This pattern keeps bbox-skeleton focused on the mathematical transformations while your adapter handles engine-specific details.
Each element has a coreTransform matrix and a transformOrigin point. The effective transformation is:
M_effective = T(origin) · M_core · T(-origin)
Where T(v) is a translation matrix. This is what getBakedTransformMatrix computes.
For an element with ancestors [root, ..., parent, element], the world matrix is:
M_world = M_root · ... · M_parent · M_element
This composition is computed by getElementWorldMatrix.
When you drag a handle in world space, you're defining a new world skeleton S_world_new. To find the required geometry changes, we:
-
Invert to local space:
S_local_new = M_context⁻¹ · S_world_newWhere
M_contextis the transformation matrix that defines the coordinate space of the skeleton. When manipulating in world space, this isM_world. During recursive group processing, this becomes each child's local matrix. -
Compute new bounding box:
bbox_new = boundingBox(S_local_new) -
For groups: Recursively project to children using relative coordinates within the group's bounding box. Each recursion level uses the child's local matrix as the new context matrix.
This context-aware approach is what allows applySkeletonInPlace to correctly handle both top-level manipulation (world space) and nested transformations (parent-relative space) with the same algorithm.
Changing transform origin from o_old to o_new requires geometry compensation. The element's visual position is determined by:
M_effective · p = T(o) · M_core · T(-o) · p
When we change o, we must adjust the local geometry by:
δ = (M_core⁻¹ - I) · (o_old - o_new)
This ensures M_effective · p remains constant. The derivation comes from requiring that the transformed origin point stays in the same world position.
bbox-skeleton has an important mathematical constraint: You cannot always resize a group with non-uniform scaling (changing aspect ratio) if it contains elements with skew or rotation.
Why? Consider a rectangle rotated 45°. Its corners form a diamond shape. If you try to "squash" this diamond horizontally while keeping the rotation at 45°, the geometry becomes inconsistent — you'd need to change the rotation angle itself, which violates the transform-preserving principle.
In mathematical terms: Non-uniform scaling of a rotated/skewed element requires modifying the skew or rotation parameters, which bbox-skeleton explicitly avoids.
Practical solutions:
- Lock aspect ratio when resizing groups with rotated children (pass
aspectRatioto projection functions) - Detect the constraint and warn users when attempting non-uniform scaling of problematic groups
- Allow it anyway and accept that results may be approximate (the system will do its best)
The library provides the tools to handle this gracefully — you decide the UX approach.
- Group resizing recursively processes all descendants. Deep hierarchies with many children may have performance implications.
- Consider debouncing during interactive dragging (update preview at 60fps, commit changes on mouse up)
- The example code includes
watchAsyncViaAnimationFramefor this purpose
bbox-skeleton makes specific design choices that may differ from other graphic libraries:
Conventional approach: Update the transform matrix when resizing bbox-skeleton approach: Update the geometry, keep transforms constant
This design prioritizes semantic preservation — if a designer set a rotation to exactly 45°, that value shouldn't drift to 44.97° due to interactive manipulations.
- Abstract mathematical layer (bbox-skeleton) — Pure geometry and matrix math
- Engine adapter layer (your code) — Bridges to your specific graphic engine
This separation makes bbox-skeleton reusable across different graphic frameworks.
Many libraries treat transform origin as a convenience feature. bbox-skeleton treats it as a fundamental property, with sophisticated compensation mathematics to allow changing it without visual side effects.
bbox-skeleton was developed for Bluepic, an expression-based graphic design engine. In Bluepic:
- Elements are defined by expressions (e.g.,
width: data.count * 50) - Transforms are parametric (keyframed animations)
- WYSIWYG manipulation must update expressions, not transforms
- The transform-preserving approach ensures animations remain valid after manual edits
However, bbox-skeleton is engine-agnostic and works equally well with:
- Canvas-based graphic libraries
- SVG editors
- WebGL rendering engines
- Any system with positioned, transformable elements
SimpleElementShape<Meta>— Leaf element with x, y, width, heightSimpleElementGroup<Meta>— Container with childrenSimpleElement<Meta>— Union of shape and groupElementBBox—{ x, y, width, height }Skeleton—[Vec2, Vec2, Vec2, Vec2](four corners)Vec2—{ x: number, y: number }ElementChangeRecord<Meta>—{ el: SimpleElementShape<Meta>, newLocalBBox: ElementBBox }
Tree traversal:
collectAllElements(slot)— Recursively iterate all elementsfindElement(globalSlot, predicateFn)— Find element by predicate
Bounding boxes & skeletons:
getElementLocalBBox(element)— Local bounding boxgetElementBaseSkeleton(element)— Untransformed corner pointsgetElementSkeleton(element)— Locally transformed cornersgetElementWorldSkeleton(globalSlot, element)— Fully transformed cornerscalcBBoxFromSkeleton(skeleton)— Bounding box from points
Matrices:
getBakedTransformMatrix(element)— Local transform with origingetElementWorldMatrix(globalSlot, element)— Composed world transform
Manipulation:
applySkeletonInPlace(el, newSkeleton, skeletonMatrix)— Project skeleton to geometrytranslateSkeleton(skeleton, delta)— Move skeleton by vector
Transform origin:
adjustLocalBBoxForNewTransformOrigin(element, newOriginAbs)— Change origin (absolute)adjustLocalBBoxForNewTransformOriginRelative(element, relativeOrigin)— Change origin (relative)
Projection (from projectSkeleton.ts):
projectSkeletonFromCorner(baseSkeleton, worldMatrix, handleIndex, handlePos, aspectRatio)— Corner resizeprojectSkeletonFromEdge(baseSkeleton, worldMatrix, edgeIndex, handlePos, aspectRatio)— Edge resizegetAspectRatioOfSkeleton(skeleton, worldMatrix)— Calculate aspect ratio
bakeOriginIntoMatrix(coreMatrix, origin)— Combine transform and origincomputeOriginCompensationDelta(coreMatrix, oldOrigin, newOrigin)— Calculate geometry shift neededapplyLinearPartOfMatrix(matrix, vector)— Apply matrix without translationskewDEG(ax, ay)— Create skew matrix from degreesradiansToDegrees(rad)— Convert radians to degrees
Contributions are welcome! This library solves a nuanced mathematical problem, and there's always room for:
- Performance optimizations
- Additional projection modes
- Better handling of edge cases
- More sophisticated constraint detection
- Improved TypeScript types
MIT
Built with mathematical rigor for graphic transformation challenges.
If you're building a WYSIWYG editor and struggling with transform vs. geometry updates, bbox-skeleton might be exactly what you need.