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]
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 @@
-Drop STL or 3MF file here
+Drop STL, 3MF, or OBJ + MTL + texture files here
โ ๏ธ โ
@@ -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(){