From 77bb3bffdfd349f05150517ea26c917d6dc328f0 Mon Sep 17 00:00:00 2001 From: David Krammer Date: Sun, 26 Apr 2026 19:58:03 +0200 Subject: [PATCH 1/2] Add colored OBJ import workflow --- 0.9.3.3.html | 309 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 256 insertions(+), 53 deletions(-) diff --git a/0.9.3.3.html b/0.9.3.3.html index c5cd873..570325c 100644 --- a/0.9.3.3.html +++ b/0.9.3.3.html @@ -236,7 +236,7 @@
-
Model
๐Ÿ“ฆ

Drop STL or 3MF here or click to browse

+
Model
๐Ÿ“ฆ

Drop STL, 3MF, or OBJ + MTL + texture here

Dimensions (mm)
mm
mm
mm
%
@@ -295,7 +295,7 @@

๐Ÿ“ Step 1 โ€” Load & Orient

-
  1. Drag an STL or 3MF file onto the viewport, or click the upload area.
  2. Use the Orient button to click a face that should sit flat on the build plate.
  3. Use Scale to resize the model if needed. Apply photo projection before scaling for best results.
+
  1. Drag an STL, 3MF, or OBJ project onto the viewport, or click the upload area. For textured OBJ files, include the .obj, .mtl, and image texture together.
  2. Use the Orient button to click a face that should sit flat on the build plate.
  3. Use Scale to resize the model if needed. Apply photo projection before scaling for best results.

๐ŸŽจ Step 2 โ€” Paint

  1. Pick a colour from the colour picker.
  2. Use Brush, Sphere, Fill, Gradient, or Photo to paint your model.
  3. Ctrl+Z / Ctrl+Y to undo/redo. Set a Base Colour for unpainted faces.

โš™๏ธ Step 3 โ€” Process & Export

@@ -434,7 +434,7 @@

๐ŸŽจ Filament Colours

-
Drop STL or 3MF file here
+
Drop STL, 3MF, or OBJ + MTL + texture files here
โš ๏ธ
Processing...
@@ -457,7 +457,7 @@

๐ŸŽจ Filament Colours

$('themeIcon').innerHTML=light?YY_LIGHT:YY_DARK; $('themeLabel').textContent=light?'Lite Mode':'Dark Mode'; } -const S={fn:'',tool:'brush',brushR:5,fillMode:'connected',hue:0,sat:1,val:1,col:new THREE.Color(1,0,0),base:new THREE.Color(.78,.78,.78),painting:false,mesh:null,geo:null,fc:null,painted:null,adj:null,fn2:null,us:[],rs:[],wf:false,wfm:null,mdl3mf:null,msz:1,processed:false,tex:null,uvs:null,texCtx:null,texW:0,texH:0,gradA:'#ff0000',gradB:'#0000ff',gradAxis:'z',gradPick:'A',fil2A:'#ffffff',fil2B:'#000000',gradMode:'all',gradSubTool:'brush',gradSel:new Set(),gradPtA:null,gradPtB:null,gradYaw:0,gradPitch:0,gradCenter:0.5, +const S={fn:'',tool:'brush',brushR:5,fillMode:'connected',hue:0,sat:1,val:1,col:new THREE.Color(1,0,0),base:new THREE.Color(.78,.78,.78),painting:false,mesh:null,geo:null,fc:null,painted:null,adj:null,fn2:null,us:[],rs:[],wf:false,wfm:null,mdl3mf:null,msz:1,processed:false,tex:null,uvs:null,texCtx:null,texW:0,texH:0,objProject:null,gradA:'#ff0000',gradB:'#0000ff',gradAxis:'z',gradPick:'A',fil2A:'#ffffff',fil2B:'#000000',gradMode:'all',gradSubTool:'brush',gradSel:new Set(),gradPtA:null,gradPtB:null,gradYaw:0,gradPitch:0,gradCenter:0.5, filHex:{c:'#00FFFF',m:'#FF00FF',y:'#FFFF00',k:'#000000',w:'#FFFFFF'},photoProjection:null}; let pend=new Map(); let selPend=new Set(); // tracks faces added to grad/photo sel during current brush stroke @@ -619,6 +619,153 @@

๐ŸŽจ Filament Colours

loadModelData(d.p,d.n,name.replace(/\.stl$/i,''),d.colors,null,null); } +function normFileName(name){return name.split(/[\\/]/).pop().toLowerCase();} +function objIdx(raw,len){const n=parseInt(raw,10);return n<0?len+n:n-1;} +function normColor3(n){const s=n.some(v=>v>1)?255:1;return[Math.max(0,Math.min(1,n[0]/s)),Math.max(0,Math.min(1,n[1]/s)),Math.max(0,Math.min(1,n[2]/s))];} +function isObjAssetName(name){return /\.(mtl|png|jpe?g|webp)$/i.test(name);} +function objProjectAssetName(re){if(!S.objProject)return'';for(const k of S.objProject.fileMap.keys())if(re.test(k))return k;return'';} +function updateObjAssetUI(){ + const box=$('objAssets');if(!box)return; + if(!S.objProject){box.style.display='none';return;} + box.style.display='block'; + const mtl=objProjectAssetName(/\.mtl$/i),tex=objProjectAssetName(/\.(png|jpe?g|webp)$/i); + $('objMtlBtn').textContent=mtl?'Replace MTL file':'Add MTL file'; + $('objTexBtn').textContent=tex?'Replace texture image':'Add texture image'; + $('objAssetStatus').textContent=`MTL: ${mtl||'none'} ยท Texture: ${tex||'none'}`; +} +function wrappedAvg3(a,b,c){const lo=Math.min(a,b,c),hi=Math.max(a,b,c);if(hi-lo>.5){a=a<.5?a+1:a;b=b<.5?b+1:b;c=c<.5?c+1:c;return((a+b+c)/3)%1;}return(a+b+c)/3;} +function parseMapLine(line){ + const toks=line.trim().split(/\s+/).slice(1),out=[]; + const skip={blendu:1,blendv:1,boost:1,mm:2,o:3,s:3,t:3,texres:1,clamp:1,bm:1,imfchan:1,type:1}; + for(let i=0;i=3)cur.kd=normColor3(n);} + else if(cur&&key==='map_kd'){const p=parseMapLine(line);if(p)cur.mapKd=p;} + } + return mats; +} +async function imageSampler(fileBuf,name){ + const blob=new Blob([fileBuf]);const url=URL.createObjectURL(blob); + try{ + const img=await new Promise((res,rej)=>{const im=new Image();im.onload=()=>res(im);im.onerror=rej;im.src=url;}); + const cv=document.createElement('canvas');cv.width=img.naturalWidth||img.width;cv.height=img.naturalHeight||img.height; + const ctx=cv.getContext('2d',{willReadFrequently:true});ctx.drawImage(img,0,0); + return{w:cv.width,h:cv.height,ctx,name,sample(u,v){ + u=Math.max(0,Math.min(1,u));v=Math.max(0,Math.min(1,v)); + const x=Math.round(u*(cv.width-1)),y=Math.round((1-v)*(cv.height-1)); + const d=ctx.getImageData(x,y,1,1).data;return[d[0]/255,d[1]/255,d[2]/255]; + }}; + }finally{URL.revokeObjectURL(url);} +} +async function loadOBJProject(objName,objBuf,fileMap){ + setLoadMsg('Parsing OBJ...'); + const txt=new TextDecoder().decode(objBuf); + const verts=[],vcols=[],uvs=[],faces=[]; + const mtllibs=[];let matName=null; + for(const raw of txt.split(/\r?\n/)){ + const line=raw.split('#')[0].trim();if(!line)continue; + const sp=line.search(/\s/),key=(sp<0?line:line.slice(0,sp)).toLowerCase(),rest=sp<0?'':line.slice(sp+1).trim(); + if(key==='v'){ + const n=rest.split(/\s+/).map(Number);if(n.length>=3){verts.push([n[0],n[1],n[2]]);vcols.push(n.length>=6?normColor3(n.slice(3,6)):null);} + }else if(key==='vt'){ + const n=rest.split(/\s+/).map(Number);if(n.length>=2)uvs.push([n[0],n[1]]); + }else if(key==='mtllib'){ + if(fileMap.has(normFileName(rest)))mtllibs.push(rest); + else for(const p of rest.split(/\s+/))if(p)mtllibs.push(p); + }else if(key==='usemtl'){ + matName=rest; + }else if(key==='f'){ + const corners=rest.split(/\s+/).map(c=>{const p=c.split('/');return{vi:objIdx(p[0],verts.length),ti:p[1]?objIdx(p[1],uvs.length):-1};}).filter(c=>c.vi>=0&&c.vic.ti>=0&&uvs[c.ti])){ + const uv0=uvs[f.c[0].ti],uv1=uvs[f.c[1].ti],uv2=uvs[f.c[2].ti]; + col=samplers.get(mat.mapKd).sample(wrappedAvg3(uv0[0],uv1[0],uv2[0]),wrappedAvg3(uv0[1],uv1[1],uv2[1])); + }else if(mat&&mat.kd){ + col=mat.kd; + }else if(f.c.every(c=>vcols[c.vi])){ + const a=vcols[f.c[0].vi],b=vcols[f.c[1].vi],c=vcols[f.c[2].vi]; + col=[(a[0]+b[0]+c[0])/3,(a[1]+b[1]+c[1])/3,(a[2]+b[2]+c[2])/3]; + } + if(col){fcArr[fi*3]=col[0];fcArr[fi*3+1]=col[1];fcArr[fi*3+2]=col[2];hasColor=true;} + else{fcArr[fi*3]=S.base.r;fcArr[fi*3+1]=S.base.g;fcArr[fi*3+2]=S.base.b;} + if(fi%100000===0)await sl(0); + } + await loadModelData(posArr,faces.length,objName.replace(/\.obj$/i,''),hasColor?fcArr:null,null,null); +} +function readFileBuffer(file){return new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>res(e.target.result);r.onerror=()=>rej(r.error);r.readAsArrayBuffer(file);});} +async function reloadCurrentOBJ(msg){ + if(!S.objProject)return; + showLoading(msg||('Loading '+S.objProject.objName+'...')); + try{ + await loadOBJProject(S.objProject.objName,S.objProject.fileMap.get(normFileName(S.objProject.objName)),S.objProject.fileMap); + updateObjAssetUI(); + }catch(e){hideLoading();alert('Error loading OBJ: '+e.message);console.error(e);} +} +async function addOBJAssetFiles(fileList){ + if(!S.objProject)return; + const files=[...fileList].filter(f=>isObjAssetName(f.name));if(!files.length)return; + for(const f of files)S.objProject.fileMap.set(normFileName(f.name),await readFileBuffer(f)); + await reloadCurrentOBJ('Updating OBJ texture files...'); +} +async function loadUploadFiles(fileList){ + const files=[...fileList];if(!files.length)return; + const obj=files.find(f=>/\.obj$/i.test(f.name)); + if(obj){ + showLoading('Loading '+obj.name+'...'); + try{ + const fileMap=new Map(); + for(const f of files)fileMap.set(normFileName(f.name),await readFileBuffer(f)); + S.objProject={objName:obj.name,fileMap}; + await loadOBJProject(obj.name,fileMap.get(normFileName(obj.name)),fileMap); + updateObjAssetUI(); + }catch(e){S.objProject=null;updateObjAssetUI();hideLoading();alert('Error loading OBJ: '+e.message);console.error(e);} + return; + } + if(S.objProject&&files.some(f=>isObjAssetName(f.name))){await addOBJAssetFiles(files);return;} + const f=files.find(f=>/\.(stl|3mf)$/i.test(f.name)); + if(f){S.objProject=null;updateObjAssetUI();loadFile(await readFileBuffer(f),f.name);} +} + // === ZIP Reader (supports STORE and DEFLATE via DecompressionStream) === async function readZip(buf){ const u8=new Uint8Array(buf),dv=new DataView(buf),files={}; @@ -968,40 +1115,8 @@

๐ŸŽจ Filament Colours

canTex.magFilter=THREE.NearestFilter; S.tex=canTex; - // Paint initial face colors to canvas โ€” use fillRect per patch (same as drawFaceOnCanvas) - { - const br2=S.base.r,bg2=S.base.g,bb2=S.base.b; - S.texCtx.fillStyle=`rgb(${Math.round(br2*255)},${Math.round(bg2*255)},${Math.round(bb2*255)})`; - S.texCtx.fillRect(0,0,texW,texH); - if(S.painted.size>0){ - setLoadMsg('Painting texture...');await sl(0); - // Batch by color for fillRect โ€” group same-color faces to minimize fillStyle changes - const cg2=new Map(); - for(const fi of S.painted){ - const rr=S.fc[fi*3],gg=S.fc[fi*3+1],bb3=S.fc[fi*3+2]; - const ck=`${Math.round(rr*255)},${Math.round(gg*255)},${Math.round(bb3*255)}`; - if(!cg2.has(ck))cg2.set(ck,{r:rr,g:gg,b:bb3,faces:[]}); - cg2.get(ck).faces.push(fi); - } - let cgN=0; - for(const[,{r:cr,g:cg3,b:cb,faces}] of cg2){ - S.texCtx.fillStyle=`rgb(${Math.round(cr*255)},${Math.round(cg3*255)},${Math.round(cb*255)})`; - for(let j=0;j๐ŸŽจ Filament Colours // vertices that differ by ~0.0001mm, causing the two triangles of a flat face to appear // non-adjacent and breaking the angle-limit fill tool on vertical faces. const pr=1e3,vk=(x,y,z)=>`${Math.round(x*pr)},${Math.round(y*pr)},${Math.round(z*pr)}`;const e2f=new Map();for(let fi=0;fi{const f=e.target.files[0];if(f){const r=new FileReader();r.onload=ev=>loadFile(ev.target.result,f.name);r.readAsArrayBuffer(f);}}); +$('fi').addEventListener('change',e=>{loadUploadFiles(e.target.files);}); +$('objMtlIn').addEventListener('change',e=>{addOBJAssetFiles(e.target.files);e.target.value='';}); +$('objTexIn').addEventListener('change',e=>{addOBJAssetFiles(e.target.files);e.target.value='';}); document.addEventListener('dragover',e=>{e.preventDefault();$('do').style.display='flex';}); document.addEventListener('dragleave',e=>{if(!e.relatedTarget)$('do').style.display='none';}); -document.addEventListener('drop',e=>{e.preventDefault();$('do').style.display='none';const f=e.dataTransfer.files[0];if(f&&/\.(stl|3mf)$/i.test(f.name)){const r=new FileReader();r.onload=ev=>loadFile(ev.target.result,f.name);r.readAsArrayBuffer(f);}}); +document.addEventListener('drop',e=>{e.preventDefault();$('do').style.display='none';loadUploadFiles(e.dataTransfer.files);}); function showLoading(msg){ $('loadAnim').style.display='block';$('pbarWrap').style.display='none'; $('pt').textContent=msg||'Loading...';$('po').classList.add('vis'); @@ -1037,7 +1154,7 @@

๐ŸŽจ Filament Colours

function hideLoading(){ setTimeout(()=>{$('loadAnim').style.display='none';$('pbarWrap').style.display='';$('po').classList.remove('vis');},200); } -function loadFile(buf,name){showLoading('Loading '+name+'...');setTimeout(()=>{if(/\.3mf$/i.test(name))load3MF(buf,name).catch(e=>{hideLoading();alert('Error loading 3MF: '+e.message);console.error(e);});else{try{const d=parseSTL(buf);loadModelData(d.p,d.n,name.replace(/\.stl$/i,''),d.colors,null,null).catch(e=>{hideLoading();alert('Error loading STL: '+e.message);console.error(e);});}catch(e){hideLoading();alert('Error parsing STL: '+e.message);}}},50);} +function loadFile(buf,name){showLoading('Loading '+name+'...');setTimeout(()=>{if(/\.3mf$/i.test(name)){S.objProject=null;updateObjAssetUI();load3MF(buf,name).catch(e=>{hideLoading();alert('Error loading 3MF: '+e.message);console.error(e);});}else if(/\.obj$/i.test(name)){const fileMap=new Map([[normFileName(name),buf]]);S.objProject={objName:name,fileMap};updateObjAssetUI();loadOBJProject(name,buf,fileMap).then(updateObjAssetUI).catch(e=>{S.objProject=null;updateObjAssetUI();hideLoading();alert('Error loading OBJ: '+e.message);console.error(e);});}else{S.objProject=null;updateObjAssetUI();try{const d=parseSTL(buf);loadModelData(d.p,d.n,name.replace(/\.stl$/i,''),d.colors,null,null).catch(e=>{hideLoading();alert('Error loading STL: '+e.message);console.error(e);});}catch(e){hideLoading();alert('Error parsing STL: '+e.message);}}},50);} function setBH(hex){if(!/^#[0-9a-f]{6}$/i.test(hex))return;S.base.set(hex);$('bsw').style.background=hex;$('bhx').value=hex;$('bnp').value=hex;applyB();} function applyB(){if(!S.geo)return;const nf=S.fc.length/3,r=S.base.r,g=S.base.g,b=S.base.b;for(let fi=0;fi๐ŸŽจ Filament Colours if(S.tex)S.tex.needsUpdate=true; } +async function paintInitialTextureCanvas(texW,texH,nf,canTex){ + if(!S.texCtx)throw new Error('Could not create texture canvas. Try a browser/device with a larger canvas limit.'); + const br8=Math.round(S.base.r*255),bg8=Math.round(S.base.g*255),bb8=Math.round(S.base.b*255); + if(!S.painted||S.painted.size===0){ + S.texCtx.fillStyle=`rgb(${br8},${bg8},${bb8})`; + S.texCtx.fillRect(0,0,texW,texH); + canTex.needsUpdate=true; + return; + } + + // Textured OBJ imports often have hundreds of thousands of unique face colours. + // Avoid building one huge array per colour; paint directly into an ImageData buffer. + if(S.painted.size>50000){ + const img=S.texCtx.createImageData(texW,texH),data=img.data; + for(let i=0;i๐ŸŽจ Filament Colours // heightNeeded = PAD + rowsNeeded*(p+PAD) must be <= texH // Solving: p <= (texW-PAD)/sqrt(nf) - PAD setLoadMsg('UV mapping...');await sl(0); - const texW=nf<8000?1024:nf<100000?2048:4096,texH=texW; const PAD=1; + const fits=(size,p)=>{ + const fpRow=Math.floor((size-PAD)/(p+PAD)); + if(fpRow<=0)return false; + const rows=Math.ceil(nf/fpRow); + return PAD+rows*(p+PAD)<=size; + }; + const maxTex=Math.max(1024,Math.min(8192,(R&&R.capabilities&&R.capabilities.maxTextureSize)||8192)); + let texW=nf<8000?1024:nf<100000?2048:4096; + while(texW>1; - const fpRow=Math.floor((texW-PAD)/(mid+PAD)); - if(fpRow===0){hi=mid-1;continue;} - const rows=Math.ceil(nf/fpRow); - if(PAD+rows*(mid+PAD)<=texH)lo=mid;else hi=mid-1; + if(fits(texW,mid))lo=mid;else hi=mid-1; } const p=lo,pw=p,ph=p; const uvOut=new Float32Array(nf*6); @@ -1833,12 +2024,22 @@

๐ŸŽจ Filament Colours

} function updateDims(){ if(!S.geo)return; + const dims=getModelDims(); + if(!dims)return; + $('dimX').value=dims.x.toFixed(2); + $('dimY').value=dims.y.toFixed(2); + $('dimZ').value=dims.z.toFixed(2); +} +function getModelDims(){ + if(!S.geo)return null; S.geo.computeBoundingBox(); const bb=S.geo.boundingBox; - const sx=bb.max.x-bb.min.x,sy=bb.max.y-bb.min.y,sz=bb.max.z-bb.min.z; - $('dimX').value=sx.toFixed(2); - $('dimY').value=sy.toFixed(2); - $('dimZ').value=sz.toFixed(2); + return{x:bb.max.x-bb.min.x,y:bb.max.y-bb.min.y,z:bb.max.z-bb.min.z}; +} +function modelSizeInfoHTML(){ + const d=getModelDims(); + if(!d)return''; + return 'Size: '+d.x.toFixed(2)+' x '+d.y.toFixed(2)+' x '+d.z.toFixed(2)+' mm'; } function onDimChange(axis,val){ if(!S.geo)return; @@ -2107,7 +2308,8 @@

๐ŸŽจ Filament Colours

S.mdl3mf=makeZip([{name:'[Content_Types].xml',data:enc2.encode(ctypes).buffer},{name:'_rels/.rels',data:enc2.encode(rels2).buffer},{name:'3D/3dmodel.model',data:enc2.encode(xp.join('')).buffer}]); fe.style.width='100%';te.textContent='Done โ€” '+palette.length+' colours, '+uid.toLocaleString()+' verts';await sl(400); ov.classList.remove('vis');$('dl-overlay').style.display='block';$('reprocess-overlay').style.display='none';S.processed=true; - $('linf').style.display='';$('linf').innerHTML='Solid ยท '+palette.length+' colours ยท '+triData2.length.toLocaleString()+' tris'; + const sizeInfo=modelSizeInfoHTML(); + $('linf').style.display='';$('linf').innerHTML=(sizeInfo?sizeInfo+'
':'')+'Solid ยท '+palette.length+' colours ยท '+triData2.length.toLocaleString()+' tris'; return; } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -2459,7 +2661,8 @@

๐ŸŽจ Filament Colours

const chColors=['var(--tx)','var(--tx)','var(--cy)','var(--mg)','var(--yl)']; const modeColors={cmy:['var(--cy)','var(--mg)','var(--yl)'],wcmy:['var(--tx)','var(--cy)','var(--mg)','var(--yl)'],kcmy:['var(--txd)','var(--cy)','var(--mg)','var(--yl)'],wkcmy:['var(--tx)','var(--txd)','var(--cy)','var(--mg)','var(--yl)'],'2c':[S.fil2A,S.fil2B]}; const cols=modeColors[mode]; - info.innerHTML='Verts: '+uid.toLocaleString()+' ยท Tris: '+triData.length.toLocaleString()+'
'+ + const sizeInfo=modelSizeInfoHTML(); + info.innerHTML=(sizeInfo?sizeInfo+'
':'')+'Verts: '+uid.toLocaleString()+' ยท Tris: '+triData.length.toLocaleString()+'
'+ M.ch.map((ch,i)=>''+ch.n[0]+':'+counts[i].toLocaleString()+'').join(' ยท ')+' ยท '+nL+' layers'; } function dl3MF(){ From d05738962af68e134c2f4e266ce60cb6bdbdf8c2 Mon Sep 17 00:00:00 2001 From: David Krammer Date: Mon, 27 Apr 2026 04:04:56 +0200 Subject: [PATCH 2/2] Enable GitHub Sponsors button --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..0d12aa3 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [davidkrammer]