
Building a free Photoshop alternative
A stroke is many tips
We reopened the AI original from Part 24 and painted on a separate layer. With the same brush size, changing the distance between stamps turns a connected stroke into separated dots.
AI-generated study photograph. Editor captures and outputs show Photoshot in use.
From the source.
Turn pointer travel into regularly spaced stamps
brush-engine.ts repeats tips along the pointer path. Spacing is max(0.5, size×spacing÷100). For this 40px brush, 15% means 6 document pixels and 150% means 60. Display zoom and document-pixel distances are different quantities.
The engine does not simply stamp once per pointer event. It carries the remaining distance to the next stamp across segments and interpolates between captured points, reducing dependence on event density.
Store settings and a seed with each stroke
Shape, flow, opacity, spacing, scatter and size variation are distinct controls. brush-dynamics.ts validates bitmap dimensions and alpha arrays. A plausible preview does not make malformed data or out-of-range settings acceptable.
Random variation also needs a reproducible path. The engine derives a sequence from a seed, and the saved strokes retain their own seeds and settings. This comparison keeps size jitter, angle jitter and scatter at zero to isolate spacing.
export type BrushPoint=Point&{pressure?:number;tiltX?:number;tiltY?:number;twist?:number;wheel?:number};
export type BrushTip={width:number;height:number;alpha:number[]};
export type BrushSettings={symmetry?:BrushSymmetry;controls?:BrushControls;minimumDiameter?:number;minimumRoundness?:number;roundnessJitter?:number;countJitter?:number;scatterBothAxes?:boolean;purity?:number;textureMode?:typeof brushMaskModes[number];textureBrightness?:number;textureContrast?:number;textureInvert?:boolean;tipModel?:'round'|'bristle'|'erodible';bristleDensity?:number;bristleThickness?:number;bristleLength?:number;bristleStiffness?:number;erosion?:number;hardness:number;opacity:number;flow:number;spacing:number;roundness:number;angle:number;sizeJitter:number;angleJitter:number;scatter:number;count:number;pressureSize:boolean;pressureOpacity:boolean;flipX:boolean;flipY:boolean;textureDepth:number;textureScale:number;tip?:BrushTip;texture?:BrushTip;hueJitter?:number;saturationJitter?:number;brightnessJitter?:number;backgroundMix?:number;backgroundColor?:string;colorPerTip?:boolean;tiltAngle?:boolean;tiltRoundness?:boolean;twistAngle?:boolean;dual?:{mode?:typeof brushMaskModes[number];size:number;spacing:number;scatter:number;count:number;tip?:BrushTip}};
export const defaultBrush:BrushSettings={hardness:100,opacity:100,flow:100,spacing:15,roundness:100,angle:0,sizeJitter:0,angleJitter:0,scatter:0,count:1,pressureSize:false,pressureOpacity:false,flipX:false,flipY:false,textureDepth:0,textureScale:4};
export function validateBrush(b:BrushSettings){if(b.symmetry)validateSymmetry(b.symmetry);validateBrushControls(b);for(const [k,min,max] of [['hardness',0,100],['opacity',0,100],['flow',0,100],['spacing',1,300],['roundness',1,100],['angle',-360,360],['sizeJitter',0,100],['angleJitter',0,180],['scatter',0,500],['count',1,16],['textureDepth',0,100],['textureScale',1,64]] as const)if(!Number.isFinite(b[k])||b[k]<min||b[k]>max)throw Error('Invalid brush '+k);if(!Number.isInteger(b.count)||['pressureSize','pressureOpacity','flipX','flipY'].some(k=>typeof b[k as keyof BrushSettings]!=='boolean'))throw Error('Invalid brush dynamics.');for(const k of ['hueJitter','saturationJitter','brightnessJitter','backgroundMix'] as const)if(b[k]!==undefined&&(!Number.isFinite(b[k])||b[k]!<0||b[k]!>100))throw Error('Invalid brush color dynamics.');for(const k of ['colorPerTip','tiltAngle','tiltRoundness','twistAngle'] as const)if(b[k]!==undefined&&typeof b[k]!=='boolean')throw Error('Invalid brush pen dynamics.');if(b.backgroundColor!==undefined&&!/^#[0-9a-f]{6}$/i.test(b.backgroundColor))throw Error('Invalid brush background color.');if(b.tip)validateTip(b.tip);if(b.texture)validateTip(b.texture);if(b.dual){const d=b.dual;for(const [key,min,max] of [['size',5,200],['spacing',25,300],['scatter',0,300],['count',1,8]] as const)if(!Number.isFinite(d[key])||d[key]<min||d[key]>max)throw Error('Invalid dual brush.');if(!Number.isInteger(d.count))throw Error('Invalid dual brush count.');if(d.tip)validateTip(d.tip);}}Inside the
calculation.
ABR import has explicit limits
brush-presets.ts checks ABR major versions 6, 7, 9 and 10 with minor versions 1 or 2, and limits files to 32MiB. It checks section lengths and sample bounds before parsing. Large tips are reduced to at most 512px and warnings are collected.
Supported settings are mapped into the internal brush model; this does not promise identical behavior for every Adobe brush. No new ABR was imported in this demonstration. The ABR description comes from inspected implementation boundaries.
Check spacing and preset recovery in the UI
On October 22, 2026, we painted horizontal strokes on Brush spacing study using a 40px Hard Round brush: 15% spacing above and 150% below. Hardness, Opacity and Flow are 100, with pressure-size and pressure-opacity disabled. The upper stroke connects; the lower one separates into dots.
The 150% configuration was saved as Journal25 Dots 40 150. Selecting that preset in a new editor window restored Size 40 and Spacing 150. Settings stored with a document stroke and the browser’s preset library are separate resources.
Read the code.
export function paintBrush(ctx:CanvasRenderingContext2D,points:BrushPoint[],size:number,color:string,b:BrushSettings,seed=1,bounds?:{width:number;height:number}){if(!points.length)return;if(b.symmetry){const matrix=ctx.getTransform(),width=bounds?.width??ctx.canvas.width/Math.hypot(matrix.a,matrix.b),height=bounds?.height??ctx.canvas.height/Math.hypot(matrix.c,matrix.d),plain={...b,symmetry:undefined};for(const transform of symmetryTransforms(b.symmetry,width,height)){ctx.save();try{ctx.transform(...transform);paintBrush(ctx,points,size,color,plain,seed,bounds);}finally{ctx.restore();}}return;}const modelTip=proceduralBrushTip(b);if(modelTip!==b.tip)b={...b,tip:modelTip};const baseTip=makeTip(b,color);let randomSeed=seed||1;const random=()=>{randomSeed^=randomSeed<<13;randomSeed^=randomSeed>>>17;randomSeed^=randomSeed<<5;return (randomSeed>>>0)/4294967296;},spacing=Math.max(.5,size*b.spacing/100);let stamps=0,dabs=0,direction=0;const initialDirection=points.length>1?Math.atan2(points[1].y-points[0].y,points[1].x-points[0].x)*180/Math.PI:0,initialRotation=points[0].twist??0;const colors=!!(b.purity||b.hueJitter||b.saturationJitter||b.brightnessJitter||b.backgroundMix),strokeColor=colors&&b.colorPerTip===false?dynamicColor(color,b,random):color;
const dab=(p:BrushPoint)=>{const count=b.countJitter?Math.max(1,Math.round(b.count*(1-random()*b.countJitter/100))):b.count,control=(key:keyof BrushControls)=>brushControl(b.controls?.[key],p,dabs,direction,initialDirection,initialRotation);for(let i=0;i<count;i++){if(++stamps>100000)throw Error('Brush stroke is too long. Use a shorter stroke or increase spacing.');const tip=colors?makeTip(b,b.colorPerTip===false?strokeColor:dynamicColor(color,b,random)):baseTip;const pressure=Math.max(.01,Math.min(1,p.pressure??1)),radius=size*Math.max((b.minimumDiameter??0)/100,(b.pressureSize?pressure:1)*(1-random()*b.sizeJitter/100)*control('size'))*(b.tipModel==='erodible'?1+Math.min(1,dabs*(b.erosion??25)/2000)*.5:1),angle=(b.angle+(b.controls?.angle&&b.controls.angle.control!=='off'?control('angle')*360:0)+(random()*2-1)*b.angleJitter+(b.twistAngle?p.twist??0:0)+(b.tiltAngle&&((p.tiltX??0)||(p.tiltY??0))?Math.atan2(p.tiltY??0,p.tiltX??0)*180/Math.PI:0))*Math.PI/180,dx=(random()*2-1)*size*b.scatter/100,dy=(b.scatterBothAxes===false?0:random()*2-1)*size*b.scatter/100;ctx.save();ctx.globalAlpha=b.flow/100*(b.pressureOpacity?pressure:1)*control('flow')*control('opacity');ctx.translate(p.x+dx,p.y+dy);ctx.rotate(angle);ctx.scale(b.flipX?-1:1,b.flipY?-1:1);const width=radius*tip.width/Math.max(tip.width,tip.height),height=radius*tip.height/Math.max(tip.width,tip.height)*Math.max((b.minimumRoundness??1)/100,b.roundness/100*control('roundness')*(b.roundnessJitter?1-random()*b.roundnessJitter/100:1))*(b.tipModel==='bristle'?Math.max(.2,1-Math.hypot(p.tiltX??0,p.tiltY??0)/90*(1-(b.bristleStiffness??50)/100)):1)*(b.tiltRoundness?Math.max(.1,Math.cos(Math.min(89,Math.hypot(p.tiltX??0,p.tiltY??0))*Math.PI/180)):1);ctx.drawImage(tip,-width/2,-height/2,width,height);ctx.restore();}dabs++;};
dab(points[0]);let distanceToNext=spacing;for(let i=1;i<points.length;i++){const a=points[i-1],z=points[i],length=Math.hypot(z.x-a.x,z.y-a.y);if(!length)continue;direction=Math.atan2(z.y-a.y,z.x-a.x)*180/Math.PI;let d=distanceToNext;for(;d<=length;d+=spacing){const t=d/length;dab({x:a.x+(z.x-a.x)*t,y:a.y+(z.y-a.y)*t,pressure:(a.pressure??1)+((z.pressure??1)-(a.pressure??1))*t,tiltX:(a.tiltX??0)+((z.tiltX??0)-(a.tiltX??0))*t,tiltY:(a.tiltY??0)+((z.tiltY??0)-(a.tiltY??0))*t,wheel:(a.wheel??1)+((z.wheel??1)-(a.wheel??1))*t,twist:(a.twist??0)+((((z.twist??0)-(a.twist??0)+540)%360)-180)*t});}distanceToNext=d-length;}export async function importAbr(bytes:Uint8Array):Promise<{presets:BrushPreset[];warnings:string[]}>{if(bytes.byteLength>32*1024*1024||bytes.byteLength<4)throw Error('ABR files must be 32 MB or smaller.');const view=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength),version=view.getUint16(0),minor=view.getUint16(2);if(![6,7,9,10].includes(version)||![1,2].includes(minor))throw Error('Import an ABR version 6, 7, 9 or 10 brush library.');let total=0;for(let offset=4;offset<bytes.length;){if(offset+12>bytes.length)throw Error('Truncated ABR section.');const type=String.fromCharCode(...bytes.subarray(offset+4,offset+8)),size=view.getUint32(offset+8),end=offset+12+size;if(end>bytes.length)throw Error('Truncated ABR section.');if(type==='samp'){for(let p=offset+12;p<end;){if(p+5>end)throw Error('Truncated ABR sample.');const length=view.getUint32(p),start=p+4,bounds=start+1+bytes[start]+(minor===1?10:264);if(bounds+19>start+length||start+length>end)throw Error('Invalid ABR sample.');const h=view.getInt32(bounds+8)-view.getInt32(bounds),w=view.getInt32(bounds+12)-view.getInt32(bounds+4);total+=w*h;if(w<1||h<1||w>8192||h>8192||total>32*1024*1024)throw Error('ABR sample images exceed the 32 MP import limit.');p=start+Math.ceil(length/4)*4;}}offset=end+(4-size%4)%4;}
const {readAbr}=await import('./vendor/psd-codec.mjs'),abr=readAbr(bytes),warnings:string[]=[],presets:BrushPreset[]=[],used=new Set<string>();const tip=(id:string):BrushTip|undefined=>{const s=abr.samples.find(s=>s.id===id);if(!s)return;used.add(id);const ratio=Math.min(1,512/Math.max(s.bounds.w,s.bounds.h)),width=Math.max(1,Math.round(s.bounds.w*ratio)),height=Math.max(1,Math.round(s.bounds.h*ratio));if(ratio<1)warnings.push('Large brush tips were resized to 512 pixels.');return {width,height,alpha:Array.from({length:width*height},(_,i)=>s.alpha[Math.min(s.bounds.h-1,Math.floor((Math.floor(i/width)+.5)/ratio))*s.bounds.w+Math.min(s.bounds.w-1,Math.floor((i%width+.5)/ratio))])};};
for(const b of abr.brushes.slice(0,256)){const shape=b.shape;if(!['computed','sampled','dynamic','tips'].includes(shape.type)){warnings.push(b.name+': unknown brush shape.');continue;}const sample=shape.type==='sampled'?tip(shape.sampledData):undefined;if(shape.type==='sampled'&&!sample){warnings.push(b.name+': missing sample.');continue;}const settings:BrushSettings={...defaultBrush,hardness:shape.type==='computed'?clamp(shape.hardness*100,0,100):100,spacing:clamp(shape.spacingOn?shape.spacing*100:10,1,300),roundness:clamp(('roundness'in shape?shape.roundness:1)*100,1,100),angle:clamp(shape.angle,-360,360),flipX:!!shape.flipX,flipY:!!shape.flipY,tip:sample,sizeJitter:clamp((b.shapeDynamics?.sizeDynamics.jitter??0)*100,0,100),angleJitter:clamp((b.shapeDynamics?.angleDynamics.jitter??0)*180,0,180),pressureSize:b.shapeDynamics?.sizeDynamics.control==='pen pressure',pressureOpacity:b.transfer?.opacityDynamics.control==='pen pressure',scatter:clamp((b.scatter?.scatterDynamics.jitter??0)*100,0,500),count:Math.round(clamp(b.scatter?.count??1,1,16)),flow:clamp((b.toolOptions?.flow??1)*100,0,100),opacity:clamp((b.toolOptions?.opacity??1)*100,0,100)};if(shape.type==='dynamic'){Object.assign(settings,{tipModel:'bristle',bristleDensity:clamp(shape.density*100,1,100),bristleThickness:clamp(shape.thickness*100,1,100),bristleLength:clamp(shape.length*100,1,100),bristleStiffness:clamp(shape.stiffness*100,1,100)});}if(shape.type==='tips'){settings.tipModel='erodible';settings.erosion=clamp((1-shape.tipsHardness)*100,0,100);const heights=shape.tipsErodibleTipHeightMap,n=shape.tipsGridSize??(heights?Math.sqrt(heights.length):0);if(heights&&Number.isInteger(n)&&n>0&&n<=512&&heights.length===n*n){const min=heights.reduce((a,b)=>Math.min(a,b),Infinity),max=heights.reduce((a,b)=>Math.max(a,b),-Infinity);settings.tip={width:n,height:n,alpha:heights.map(v=>max===min?255:(v-min)/(max-min)*255)};}}settings.minimumDiameter=clamp((b.shapeDynamics?.minimumDiameter??0)*100,0,100);settings.minimumRoundness=clamp((b.shapeDynamics?.minimumRoundness??.01)*100,1,100);settings.roundnessJitter=clamp((b.shapeDynamics?.roundnessDynamics.jitter??0)*100,0,100);settings.countJitter=clamp((b.scatter?.countDynamics.jitter??0)*100,0,100);settings.scatterBothAxes=b.scatter?.bothAxes??true;settings.controls={};for(const [key,d]of Object.entries({size:b.shapeDynamics?.sizeDynamics,angle:b.shapeDynamics?.angleDynamics,roundness:b.shapeDynamics?.roundnessDynamics,opacity:b.transfer?.opacityDynamics,flow:b.transfer?.flowDynamics}))if(d)settings.controls[key as keyof typeof settings.controls]={control:d.control,steps:Math.round(clamp(d.steps||25,1,10000)),minimum:clamp(d.minimum*100,0,100)};if(settings.controls.size?.control==='pen pressure')settings.pressureSize=false;if(settings.controls.opacity?.control==='pen pressure')settings.pressureOpacity=false;if(b.colorDynamics){settings.purity=clamp(b.colorDynamics.purity*100,-100,100);settings.hueJitter=clamp(b.colorDynamics.hue*100,0,100);settings.saturationJitter=clamp(b.colorDynamics.saturation*100,0,100);settings.brightnessJitter=clamp(b.colorDynamics.brightness*100,0,100);settings.backgroundMix=clamp(b.colorDynamics.foregroundBackground.jitter*100,0,100);settings.colorPerTip=!!b.colorDynamics.perTip;if(b.colorDynamics.foregroundBackground.control!=='off')warnings.push(b.name+': foreground/background control is imported as jitter.');}settings.tiltAngle=false;settings.twistAngle=false;settings.tiltRoundness=false;if(b.dualBrush){const d=b.dualBrush;if((['normal',...brushMaskModes] as string[]).includes(d.blendMode.replaceAll(' ','-'))&&['computed','sampled'].includes(d.shape.type)){const dualTip=d.shape.type==='sampled'?tip(d.shape.sampledData):undefined;if(d.shape.type!=='sampled'||dualTip){settings.dual={mode:(d.blendMode==='normal'?'multiply':d.blendMode.replaceAll(' ','-')) as typeof brushMaskModes[number],size:clamp(d.shape.size/Math.max(1,shape.size)*100,5,200),spacing:clamp(d.spacing*100,25,300),scatter:clamp((d.scatterDynamics.jitter??0)*100,0,300),count:Math.round(clamp(d.count,1,8)),tip:dualTip};warnings.push(b.name+': dual tip mask imported; Adobe placement dynamics may differ.');}}if(!settings.dual)warnings.push(b.name+': this dual-tip mode is unsupported.');}validateBrush(settings);presets.push({id:crypto.randomUUID(),name:b.name||'Imported brush',size:clamp(shape.size,1,300),settings});if(b.texture){const t=b.texture,pattern=abr.patterns.find(p=>p.id===t.id);if(pattern){const width=Math.min(512,pattern.bounds.w),height=Math.min(512,pattern.bounds.h);settings.texture={width,height,alpha:Array.from({length:width*height},(_,i)=>{const x=Math.min(pattern.bounds.w-1,Math.floor((i%width+.5)/width*pattern.bounds.w)),y=Math.min(pattern.bounds.h-1,Math.floor((Math.floor(i/width)+.5)/height*pattern.bounds.h)),k=(y*pattern.bounds.w+x)*4;return Math.round((pattern.data[k]*.2126+pattern.data[k+1]*.7152+pattern.data[k+2]*.0722)*pattern.data[k+3]/255);})};settings.textureDepth=clamp(t.depth*100,0,100);settings.textureScale=clamp(t.scale,1,64);settings.textureInvert=t.invert;settings.textureBrightness=clamp(t.brightness,-100,100);settings.textureContrast=clamp(t.contrast,-100,100);const mode=t.blendMode.replaceAll(' ','-');if((brushMaskModes as readonly string[]).includes(mode))settings.textureMode=mode as typeof brushMaskModes[number];}else warnings.push(b.name+': embedded texture resource is missing.');validateBrush(settings);}}
if(!abr.brushes.length)for(const s of abr.samples.slice(0,256))presets.push({id:crypto.randomUUID(),name:'Sample '+(presets.length+1),size:clamp(Math.max(s.bounds.w,s.bounds.h),1,300),settings:{...defaultBrush,tip:tip(s.id)}});if(!presets.length)throw Error('No supported sampled or round brushes found.');return {presets,warnings:[...new Set(warnings)]};}function database(){return new Promise<IDBDatabase>((resolve,reject)=>{const q=indexedDB.open('photoshot-brush-library',1);q.onupgradeneeded=()=>q.result.createObjectStore('presets');q.onsuccess=()=>resolve(q.result);q.onerror=()=>reject(q.error);});}
export async function loadBrushPresets(){const db=await database();try{return await new Promise<BrushPreset[]>((resolve,reject)=>{const q=db.transaction('presets').objectStore('presets').get('library');q.onsuccess=()=>{try{const list=q.result??[];if(!Array.isArray(list)||list.length>256)throw Error('Invalid brush library.');for(const p of list){if(typeof p.name!=='string'||p.name.length>200||!Number.isFinite(p.size)||p.size<1||p.size>300)throw Error('Invalid brush preset.');validateBrush(p.settings);}resolve(list);}catch(e){reject(e);}};q.onerror=()=>reject(q.error);});}finally{db.close();}}
export async function saveBrushPresets(presets:BrushPreset[]){if(presets.length>256)throw Error('Brush library holds up to 256 custom presets.');const db=await database();try{await new Promise<void>((resolve,reject)=>{const tx=db.transaction('presets','readwrite');tx.objectStore('presets').put(presets,'library');tx.oncomplete=()=>resolve();tx.onerror=()=>reject(tx.error);tx.onabort=()=>reject(tx.error);});}finally{db.close();}}In the editor.

Separate hardware tests from automated pointers
The saved project contains two strokes with spacing 15 and 150, both size 40. Today’s input used automated pointer drags, not measurements of a physical pen’s pressure, tilt or rotation. Preset recovery was checked in a new window sharing the same browser storage.
The stored brush-advanced-ui report explicitly covers Chrome’s pen-event route, not hardware-specific drivers. The render report’s deterministic output and advanced-setting preservation concern designated historical cases. The portrait is AI-generated and workspace images are actual captures. Part 26 covers clone, healing and patch tools.
Original and brush spacing.


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