
Building a free Photoshop alternative
Keep paths inside shapes
We placed an ellipse and rectangle in the empty space beside a new AI portrait, then subtracted the front shape. This journal checks whether the paths and operations remain, rather than keeping only a picture of the cutout.
AI-generated study photograph. Editor captures and outputs show Photoshot in use.
From the source.
A shape stores paint and coordinates
shapes.ts stores basic geometry, fill, stroke, stroke width and corner radius. Custom paths route through compound-path rendering. Layer placement and internal path coordinates work together so the same structure can move and resize.
shape-paths.ts represents an ellipse with four Bézier segments. The coefficient 0.5522847498307936 places control points for a circular-arc approximation. Vector representation does not imply mathematically exact equivalence for every curve.
Retain the paths after combining
shape-boolean.ts distinguishes combine, subtract, intersect and exclude. It incorporates input-layer transforms into path coordinates and retains component paths and operations in the new shape. The bottom shape supplies fill and stroke.
Combination requires 2–32 shapes in the same group. Locks, masks, open paths and certain transforms or clipping are rejected, and subtraction/intersection do not accept arbitrary compound paths. These constraints matter when describing editable vector support.
export type Shape={gradient?:ShapeGradient;strokeGradient?:ShapeGradient;dash?:number[];dashOffset?:number;lineCap?:CanvasLineCap;lineJoin?:CanvasLineJoin;paths?:PathShape[];type:'rectangle'|'ellipse'|'polygon'|'line';fill:string;stroke:string;strokeWidth:number;radius:number;sides:number};
export const defaultShape:Shape={type:'rectangle',fill:'#bfff50',stroke:'#000000',strokeWidth:0,radius:0,sides:5};
export function validateShape(s:Shape){if(s)validateShapePaint(s);if(s?.paths)validatePaths([{id:'shape',name:'Shape',paths:s.paths}]);if(!s||!['rectangle','ellipse','polygon','line'].includes(s.type)||![s.fill,s.stroke].every(c=>c==='none'||/^#[0-9a-f]{6}$/i.test(c))||!Number.isFinite(s.strokeWidth)||s.strokeWidth<0||s.strokeWidth>300||!Number.isFinite(s.radius)||s.radius<0||s.radius>8192||!Number.isInteger(s.sides)||s.sides<3||s.sides>100)throw Error('Invalid shape settings.');}
export function paintShape(ctx:CanvasRenderingContext2D,l:Layer){const s=l.shape!;validateShape(s);if(s.paths){paintCompoundShape(ctx,l);return;}ctx.save();ctx.beginPath();for(const p of shapePaths(l)){if(!p.knots.length)continue;ctx.moveTo(p.knots[0].points[2],p.knots[0].points[3]);for(let i=1;i<p.knots.length+(p.open?0:1);i++){const a=p.knots[i-1].points,b=p.knots[i%p.knots.length].points;ctx.bezierCurveTo(a[4],a[5],b[0],b[1],b[2],b[3]);}if(!p.open)ctx.closePath();}if(s.fill!=='none'&&s.type!=='line'){ctx.fillStyle=shapePaint(ctx,s,'fill',l.width,l.height);ctx.fill();}if(s.stroke!=='none'&&s.strokeWidth){ctx.strokeStyle=shapePaint(ctx,s,'stroke',l.width,l.height);setupShapeStroke(ctx,s);ctx.stroke();}ctx.restore();}Inside the
calculation.
Paint and stroke settings need validation
shape-paint-style.ts validates gradient type, angle, scale and 2–32 stops with positions, colors and opacity. Dash lengths, caps and joins have their own checks. Retaining geometry and preserving paint semantics are separate tasks.
External gradients with custom midpoints or placement have support boundaries. The code distinguishes preserving a native appearance from editing that setting directly. An editable label does not guarantee reconstruction of every external parameter.
Subtract a rectangle from an ellipse
On October 22, 2026, we created a 460×460 ellipse at X100/Y240 on a 1536×1024 AI original. It uses a black-to-white linear gradient and a 6px black stroke. A 300×220 rectangle was placed at X350/Y360, then both layers were selected.
Subtract Front Shape produced Combined Shape. Edit Shape Paths exposed Path 1 and Path 2, with subtract retained on the second path. We did not drag an individual anchor to a new position in this demonstration; the direct check confirms retained, selectable path components.
Read the code.
export type ShapeOperation='combine'|'subtract'|'intersect'|'exclude';
export function combineShapes(doc:StudioDoc,ids:string[],operation:ShapeOperation):StudioDoc{if(!['combine','subtract','intersect','exclude'].includes(operation))throw Error('Invalid shape operation.');const layers=doc.layers.filter(l=>ids.includes(l.id));if(layers.length<2||layers.length>32)throw Error('Select 2 to 32 shape layers.');for(const l of layers)if(!l.shape||l.shape.type==='line'||l.shape.paths?.some(p=>p.open)||layerLocked(doc,l)||l.mask||l.vectorMask||l.gridWarp||l.puppet||l.strokes.length||l.clipped)throw Error('Select unlocked, unmasked filled shape layers.');if(new Set(layers.map(l=>l.groupId??'')).size!==1)throw Error('Choose shapes in the same group.');const paths:PathShape[]=[];for(let index=0;index<layers.length;index++){const l=layers[index],matrix=homography(quadPoints(l)),local=shapePaths(l).map(p=>({...p,knots:p.knots.map(k=>({...k,points:k.points.map((n,i)=>i%2?(l.flipY?1-n/l.height:n/l.height):(l.flipX?1-n/l.width:n/l.width))}))}));if(local.length>1&&(operation!=='combine'||index>0&&local.some(p=>p.operation&&p.operation!=='combine')))throw Error('For subtraction/intersection, use single-path shapes. Compound unions remain editable.');for(const p of local)paths.push({...projectPath(p,matrix),operation:index===0?p.operation??'combine':operation});}const values=paths.flatMap(p=>p.knots.flatMap(k=>[0,2,4].map(i=>({x:k.points[i],y:k.points[i+1]})))),pad=layers[0].shape!.strokeWidth/2,left=Math.floor(Math.min(...values.map(p=>p.x))-pad),top=Math.floor(Math.min(...values.map(p=>p.y))-pad),right=Math.ceil(Math.max(...values.map(p=>p.x))+pad),bottom=Math.ceil(Math.max(...values.map(p=>p.y))+pad),width=Math.max(1,right-left),height=Math.max(1,bottom-top);if(width*height>16000000)throw Error('Combined shapes currently support up to 16 MP.');const base=layers[0],shape={...base.shape!,radius:0,paths:paths.map(p=>({...p,knots:p.knots.map(k=>({...k,points:k.points.map((n,i)=>i%2?(n-top)/height:(n-left)/width)}))}))},result=makeLayer({name:'Combined Shape',kind:'paint',x:left,y:top,width,height,shape,groupId:base.groupId,opacity:base.opacity,fill:base.fill,blend:base.blend,effects:base.effects}),first=doc.layers.indexOf(base),remaining=doc.layers.filter(l=>!ids.includes(l.id));remaining.splice(first,0,result);const next={...doc,layers:remaining};validateDoc(next);return next;}import type {Layer} from './document.ts';import type {PathShape} from './paths.ts';
export function shapePaths(layer:Layer):PathShape[]{const s=layer.shape!;if(s.paths)return s.paths.map(p=>({...p,knots:p.knots.map(k=>({...k,points:k.points.map((n,i)=>n*(i%2?layer.height:layer.width))}))}));const inset=s.strokeWidth/2,w=Math.max(0,layer.width-s.strokeWidth),h=Math.max(0,layer.height-s.strokeWidth),x=inset,y=inset,k=.5522847498307936,knot=(x:number,y:number,ix=x,iy=y,ox=x,oy=y)=>({linked:true,points:[ix,iy,x,y,ox,oy]}),path:PathShape={open:s.type==='line',operation:'combine',fillRule:'non-zero',knots:[]};if(s.type==='ellipse'){const cx=x+w/2,cy=y+h/2,rx=w/2,ry=h/2;path.knots=[knot(cx,y,cx-k*rx,y,cx+k*rx,y),knot(x+w,cy,x+w,cy-k*ry,x+w,cy+k*ry),knot(cx,y+h,cx+k*rx,y+h,cx-k*rx,y+h),knot(x,cy,x,cy+k*ry,x,cy-k*ry)];}else if(s.type==='polygon'){path.knots=Array.from({length:s.sides},(_,i)=>{const a=-Math.PI/2+i*Math.PI*2/s.sides;return knot(layer.width/2+Math.cos(a)*w/2,layer.height/2+Math.sin(a)*h/2);});}else if(s.type==='line')path.knots=[knot(x,y),knot(x+w,y+h)];else{const r=Math.min(s.radius,w/2,h/2);path.knots=r?[knot(x+r,y,x+r-k*r,y),knot(x+w-r,y,x+w-r,y,x+w-r+k*r,y),knot(x+w,y+r,x+w,y+r-k*r),knot(x+w,y+h-r,x+w,y+h-r,x+w,y+h-r+k*r),knot(x+w-r,y+h,x+w-r+k*r,y+h),knot(x+r,y+h,x+r,y+h,x+r-k*r,y+h),knot(x,y+h-r,x,y+h-r+k*r),knot(x,y+r,x,y+r,x,y+r-k*r)]:[knot(x,y),knot(x+w,y),knot(x+w,y+h),knot(x,y+h)];}return [path];}export type ShapeGradient={type:'linear'|'radial'|'reflected';angle:number;scale:number;reverse:boolean;stops:{location:number;color:string;opacity:number}[]};
export const defaultShapeGradient=():ShapeGradient=>({type:'linear',angle:0,scale:100,reverse:false,stops:[{location:0,color:'#000000',opacity:1},{location:1,color:'#ffffff',opacity:1}]});
export function validateShapePaint(s:Shape){for(const g of [s.gradient,s.strokeGradient])if(g){if(!['linear','radial','reflected'].includes(g.type)||!Number.isFinite(g.angle)||Math.abs(g.angle)>360||!Number.isFinite(g.scale)||g.scale<1||g.scale>1000||typeof g.reverse!=='boolean'||!Array.isArray(g.stops)||g.stops.length<2||g.stops.length>32||g.stops.some((p,i)=>!Number.isFinite(p.location)||p.location<0||p.location>1||i>0&&p.location<g.stops[i-1].location||!/^#[0-9a-f]{6}$/i.test(p.color)||!Number.isFinite(p.opacity)||p.opacity<0||p.opacity>1))throw Error('Invalid shape gradient.');}if(s.dash&&(s.dash.length>16||s.dash.some(n=>!Number.isFinite(n)||n<.1||n>10000)))throw Error('Dash lengths must be between 0.1 and 10000 pixels.');if(s.dashOffset!==undefined&&(!Number.isFinite(s.dashOffset)||Math.abs(s.dashOffset)>10000))throw Error('Invalid dash offset.');if(s.lineCap&&!['butt','round','square'].includes(s.lineCap)||s.lineJoin&&!['round','bevel','miter'].includes(s.lineJoin))throw Error('Invalid shape stroke style.');}In the editor.

Separate reopening from historical pixel errors
A .layerstudio save, reopen and second save preserved the entire compound-shape layer exactly, including anchors, combine/subtract operations, gradient and 6px stroke. This direct test used Photoshot’s project format.
The stored shape-paint-native report records editable vectors for designated PSD/PSB files, with nonzero pixel differences. Its linear case has mean absolute error about 0.647 and maximum 2; the dashed case has about 0.153 and maximum 255. A low average does not make every pixel identical. The model is AI-generated and workspace images are actual captures. Part 25 covers brushes and ABR.
Original and vector subtraction.


AI-generated study photograph. Editor captures and outputs show Photoshot in use.