Four-corner path warp
Full-page experience · Open playground
Bend a rocket and its flight grid with four draggable corners. Compare bilinear and perspective warps.
Try it
Section titled “Try it”Drag the mint corners, switch Bilinear / Perspective, and compare the same shape.
What to learn
Section titled “What to learn”- Deform continuous geometry with four corner handles.
- Keep original geometry independent of derived previews.
Integration boundaries
Section titled “Integration boundaries”- quadTransform returns independent line geometry; the original outline is preserved.
- Bilinear can fold or bend geometry. Perspective preserves straight lines and needs convex corners.
This is a Pibbl Canvas example. Its mount owns its runtime resources; call its controller’s dispose method when removing it. The preview starts automatically unless reduced motion is enabled; Stop disposes the mount. Preserve the source’s logical canvas size and aspect ratio. Native HTML is not captured by Canvas filters.
Related APIs
Section titled “Related APIs”Complete source
Section titled “Complete source”Host setup for this source: use a canvas with width 900 and height 580, compile with jsx: "react-jsx" and jsxImportSource: "@pibbl/core", import its default mount, and call const controller = mount(canvas) after attaching the canvas. Call controller.dispose() before removing it.
path-warp.example.tsx
Section titled “path-warp.example.tsx”import { pibbl, Path, PathGeometry, Circle, Line, Rectangle, Text, useConst, useSignal, useRef, type PibblController, type PibblPointerEvent, type PathQuadCorners, type PathQuadMode, transformPath, quadTransform } from '@pibbl/core';
const source = { x: 0, y: 0, width: 240, height: 300 };const initial: PathQuadCorners = { topLeft: { x: 425, y: 156 }, topRight: { x: 735, y: 126 }, bottomLeft: { x: 380, y: 464 }, bottomRight: { x: 776, y: 444 },};const names = ["topLeft", "topRight", "bottomRight", "bottomLeft"] as const;const labels = ["TOP LEFT", "TOP RIGHT", "BOTTOM RIGHT", "BOTTOM LEFT"];
/** An asymmetric compound outline: the porthole winds opposite the hull. */function rocket() { const hull = new PathGeometry(); hull.moveTo(120, 20); hull.bezierCurveTo(155, 42, 174, 94, 170, 153); hull.lineTo(211, 198); hull.lineTo(220, 240); hull.lineTo(160, 211); hull.quadraticCurveTo(145, 230, 92, 220); hull.lineTo(40, 247); hull.lineTo(53, 191); hull.lineTo(80, 157); hull.bezierCurveTo(77, 92, 86, 48, 120, 20); hull.closePath(); hull.moveTo(143, 111); hull.arc(120, 111, 23, 0, -Math.PI * 2, true); hull.closePath(); const stripe = new PathGeometry(); stripe.moveTo(89, 173); stripe.quadraticCurveTo(127, 186, 167, 168); stripe.lineTo(164, 186); stripe.quadraticCurveTo(127, 203, 90, 190); stripe.closePath(); const flame = new PathGeometry(); flame.moveTo(99, 232); flame.bezierCurveTo(77, 265, 119, 275, 117, 296); flame.bezierCurveTo(152, 267, 152, 247, 143, 232); flame.closePath(); const shine = new PathGeometry(); shine.moveTo(105, 64); shine.quadraticCurveTo(94, 80, 94, 119); const grid = new PathGeometry(); for (let i = 0; i <= 6; i++) { grid.moveTo(i * 40, 0); grid.lineTo(i * 40, 300); grid.moveTo(0, i * 50); grid.lineTo(240, i * 50); } return [grid, flame, hull, stripe, shine];}
function WarpStudy() { const original = useConst(rocket); const transform = (corners: PathQuadCorners, mode: PathQuadMode) => original.map((path) => transformPath(path, quadTransform({ source, destination: corners, mode }), { tolerance: 0.3, maxSegments: 20_000 }), ); const state = useSignal( useConst(() => ({ corners: initial, mode: "bilinear" as PathQuadMode, paths: transform(initial, "bilinear"), error: "", })), ); const update = (corners: PathQuadCorners, mode: PathQuadMode) => { try { state.set({ corners, mode, paths: transform(corners, mode), error: "" }); } catch (error) { if (!(error instanceof RangeError)) throw error; state.set({ corners, mode, paths: state.get().paths, error: mode === "perspective" ? "Keep corners convex, or try Bilinear." : "Move corners closer to the canvas.", }); } }; const drag = useRef<{ pointerId: number; corner: keyof PathQuadCorners; } | null>(null); const reference = useConst(() => { const path = original[2].clone(); path.transform({ a: 0.55, d: 0.55, e: 85, f: 220 }); return path; }); const current = state.get(); const stop = (event: PibblPointerEvent) => { if (drag.current?.pointerId !== event.pointerId) return; drag.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }; const label = ( value: string, x: number, y: number, font = "11px monospace", fill = "#8d99b9", ) => ( <Text style={{ left: x, top: y, font, fill }} pointerEvents="none"> {value} </Text> ); return ( <> <Rectangle style={{ width: 900, height: 580, fill: "#0c1223" }} pointerEvents="none" /> {Array.from({ length: 46 }, (_, i) => ( <Circle key={i} style={{ cx: 28 + ((i * 173) % 845), cy: 115 + ((i * 97) % 390), radius: i % 3 === 0 ? 1.4 : 0.7, fill: i % 3 === 0 ? "#677896" : "#2d3b54", }} pointerEvents="none" /> ))} {label("Pibbl / GEOMETRY LAB", 30, 24, "11px monospace", "#8df0d1")} {label( "Bend the flight plan.", 28, 47, "bold 32px sans-serif", "#f0f0ff", )} {label( "Four corners. One continuous transformation.", 30, 91, "13px sans-serif", )} <Line style={{ coords: [ [30, 122], [298, 122], ], stroke: "#29334b", strokeWidth: 1, }} pointerEvents="none" /> {label("01 / ORIGINAL", 32, 164)} {label( "A little rocket. A lot of possibility.", 32, 188, "12px sans-serif", )} <Rectangle style={{ left: 85, top: 220, width: 132, height: 165, stroke: "#35425e", strokeWidth: 1, }} pointerEvents="none" /> <Path style={{ d: reference, fill: "#34425d", stroke: "#6c7d9f", strokeWidth: 1, }} pointerEvents="none" /> {label("240 × 300 / LOCAL SPACE", 62, 409, "10px monospace")} {label("02 / WARPED", 380, 86, "11px monospace", "#8df0d1")} <Path style={{ d: current.paths[0], stroke: "#223951", strokeWidth: 1 }} pointerEvents="none" /> <Path style={{ d: current.paths[1], fill: "#ff916c", stroke: "#ffc49f", strokeWidth: 1.5, }} pointerEvents="none" /> <Path style={{ d: current.paths[2], fill: "#c8bcff", stroke: "#eee8ff", strokeWidth: 2, }} pointerEvents="none" /> <Path style={{ d: current.paths[3], fill: "#8772dd" }} pointerEvents="none" /> <Path style={{ d: current.paths[4], stroke: "#ffffff", strokeWidth: 3 }} pointerEvents="none" /> <Line style={{ coords: [...names, names[0]].map( (name) => [current.corners[name].x, current.corners[name].y] as const, ), stroke: "#528d89", strokeWidth: 1, }} pointerEvents="none" /> {names.map((name, i) => { const p = current.corners[name]; return label( labels[i], p.x - 24, p.y + (i < 2 ? -28 : 22), "9px monospace", "#a5d9cf", ); })} {names.map((name) => { const p = current.corners[name]; return ( <> <Circle key={`${name}-halo`} style={{ cx: p.x, cy: p.y, radius: 15, fill: "#163b42" }} pointerEvents="none" /> <Circle key={name} style={{ cx: p.x, cy: p.y, radius: 8, fill: "#8df0d1", stroke: "#e5fff7", strokeWidth: 2, cursor: "grab", }} onPointerDown={(event) => { event.currentTarget.setPointerCapture(event.pointerId); drag.current = { pointerId: event.pointerId, corner: name }; }} onPointerMove={(event) => { if ( drag.current?.pointerId !== event.pointerId || drag.current.corner !== name ) return; const corners = { ...state.get().corners, [name]: { x: event.x, y: event.y }, }; update(corners, state.get().mode); }} onPointerUp={stop} onPointerCancel={stop} onLostPointerCapture={() => { drag.current = null; }} /> </> ); })} {(["bilinear", "perspective"] as const).map((mode, i) => ( <> <Rectangle key={mode} style={{ left: 30 + i * 126, top: 434, width: 118, height: 32, fill: current.mode === mode ? "#8df0d1" : "#202c43", cursor: "pointer", }} onClick={() => update(state.get().corners, mode)} /> {label( mode === "bilinear" ? "Bilinear" : "Perspective", 45 + i * 126, 444, "12px sans-serif", current.mode === mode ? "#102d2a" : "#d6ddf1", )} </> ))} {label( current.mode === "bilinear" ? "BILINEAR / RUBBER SHEET" : "PERSPECTIVE / PROJECTED", 32, 479, "10px monospace", "#8df0d1", )} {current.error && ( <> <Rectangle style={{ left: 340, top: 490, width: 515, height: 20, fill: "#492d32", }} pointerEvents="none" /> {label( "PREVIOUS VALID PREVIEW", 350, 495, "9px monospace", "#ffc2a3", )} {label(current.error, 546, 495, "10px sans-serif", "#ffe2d2")} </> )} <Rectangle style={{ left: 30, top: 512, width: 840, height: 44, fill: "#151e32" }} pointerEvents="none" /> {label("DRAG THE MINT HANDLES", 46, 528, "10px monospace", "#e3e8fa")} {label( `${current.paths.reduce((sum, path) => sum + path.segmentCount, 0)} segments · 0.3 tolerance`, 294, 528, "10px monospace", )} <Rectangle style={{ left: 758, top: 520, width: 94, height: 28, fill: "#293651", cursor: "pointer", }} onClick={() => update(initial, "bilinear")} /> {label("RESET ↗", 779, 528, "10px monospace", "#e3e8fa")} </> );}
export default function mount(canvas: HTMLCanvasElement): PibblController { return pibbl(canvas, <WarpStudy />);}Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.