From f8aa51d80cbacab038d9e4ada82e2e01c30ade0d Mon Sep 17 00:00:00 2001 From: 2s0ckz <58380524+2s0ckz@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:36:35 -0400 Subject: [PATCH 1/8] Added option to load .obj files with textures. Switched from frequency-weighted solid colour clustering to perceptual, diversity-preserving clustering so many shades of a single color do not crowd out distinct colors. Added color palette option for user-defined solid colours. Added an export preview visualization option. Added recoloring tools in preview mode. --- 0.9.3.3.html | 509 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 443 insertions(+), 66 deletions(-) diff --git a/0.9.3.3.html b/0.9.3.3.html index c5cd873..7621b77 100644 --- a/0.9.3.3.html +++ b/0.9.3.3.html @@ -230,18 +230,18 @@ .photo-upload-area{border:2px dashed var(--bd);border-radius:8px;padding:14px;text-align:center;cursor:pointer;transition:all .2s;margin-bottom:8px} .photo-upload-area:hover{border-color:var(--ac);background:var(--ag)} .photo-upload-area p{font-size:12px;color:var(--txd);margin-top:4px} - +.palette-row{display:flex;gap:6px;margin-bottom:6px}.palette-row input[type=color]{width:30px;height:30px}.palette-row .pal-del{width:28px;background:var(--sf2);color:var(--txd);border:1px solid var(--bd);border-radius:5px}.palette-actions{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-top:6px}
-
Model
πŸ“¦

Drop STL or 3MF here or click to browse

+
Model
πŸ“¦

Drop STL, 3MF, or OBJ bundle here or click to browse

Dimensions (mm)
mm
mm
mm
%
- - + +
- + β˜• Buy me a coffee
@@ -360,7 +360,7 @@

Dithered export (CMY / WCMY / KCMY / WKCMY / 2-Filament)

2 Filaments β€” dual nozzle, dithers between two chosen colours.

Solid Colours β€” No Dithering ✦

Exports each painted face as a solid flat colour. This is NOT the dithered colour-blending export β€” it's for use with normal multi-colour FDM printing. Use this if you want to paint your model and print it on a multi-filament printer (like a Bambu AMS) using standard solid filament colours, without any CMY dithering. No special slicer settings required.

-

The Palette Size option reduces all painted colours to a fixed number using colour clustering (k-means). Set this to match the number of filament slots in your slicer β€” e.g. choose 8 colours for an 8-slot AMS. Colours are automatically merged toward their nearest neighbours so the most visually distinct colours are preserved.

+

The Palette Size option reduces all painted colours to a fixed number using diversity-aware perceptual clustering. It compresses dominance from large same-colour areas, so a model with many black shades and small blue accents will keep black and blue instead of filling the palette with several blacks.

Re-importing into Primed3D: The Solid Colours export also works as a project save format. Export as Solid (Full Spectrum), then re-import the resulting .3mf into Primed3D to continue editing β€” all your painted colours are preserved on the faces and can be further refined with the brush, gradient, or photo tools before re-exporting as a dithered CMY version.

Filament Colours

Use the 🎨 Filament Colours button to set the actual hex codes of your filaments. This only affects how the slicer preview displays β€” the dithering ratios themselves are always computed using ideal CMY theory regardless of your specific filament brand.

@@ -411,7 +411,7 @@

Thanks for using Primed3D! πŸŽ‰

-
+
Camera Views
@@ -434,7 +434,7 @@

🎨 Filament Colours

-
Drop STL or 3MF file here
+
Drop STL, OBJ, MTL, texture, or 3MF file here
⚠️
Processing...
@@ -458,7 +458,7 @@

🎨 Filament Colours

$('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, -filHex:{c:'#00FFFF',m:'#FF00FF',y:'#FFFF00',k:'#000000',w:'#FFFFFF'},photoProjection:null}; +filHex:{c:'#00FFFF',m:'#FF00FF',y:'#FFFF00',k:'#000000',w:'#FFFFFF'},solidPaletteMode:'auto',solidGeometryMode:'faces',solidSubdivDepth:3,customPalette:['#000000','#FFFFFF','#0066FF','#FF0000'],recolorFrom:'#000000',recolorTol:0.08,exportPreview:false,exportPreviewMesh:null,exportPreviewWfm:null,exportPreviewAdj:null,exportPreviewFn:null,exportSolid:null,photoProjection:null,objSource:null}; let pend=new Map(); let selPend=new Set(); // tracks faces added to grad/photo sel during current brush stroke let fillHoverFi=-1; // last face index the fill-angle preview was computed for @@ -541,6 +541,7 @@

🎨 Filament Colours

vp.addEventListener('mousemove',e=>{lastMx=e.clientX;lastMy=e.clientY; const wantCursor=S.mesh&&!mouseOverUI&&( S.tool==='brush'||S.tool==='sphere-brush'|| + ((S.tool==='recolor-brush'||S.tool==='recolor-sphere')&&S.exportPreview&&S.exportPreviewMesh)|| (S.tool==='gradient'&&S.gradMode==='sel'&&S.gradSubTool==='brush')|| (S.tool==='photo'&&S.photoMode==='sel'&&S.photoSubTool==='brush')); if(wantCursor){ @@ -934,9 +935,9 @@

🎨 Filament Colours

await loadModelData(finalPos,nf,name.replace(/\.3mf$/i,''),hasColors&&cc>0?finalFC:null,null,null); }catch(e){alert('Error loading 3MF: '+e.message);console.error(e);} } -async function loadModelData(posArr,nf,name,faceColors,_uvs,_texCanvas){ +async function loadModelData(posArr,nf,name,faceColors,_uvs,_texCanvas,_objSource){ S.fn=name;if(S.mesh){sc.remove(S.mesh);if(S.wfm)sc.remove(S.wfm);} - S.tex=null;S.uvs=null;S.texCtx=null;S.texW=0;S.texH=0;S.uvScale=1; + S.tex=null;S.uvs=null;S.texCtx=null;S.texW=0;S.texH=0;S.uvScale=1;S.objSource=_objSource||null; const geo=new THREE.BufferGeometry(); geo.setAttribute('position',new THREE.BufferAttribute(posArr,3));geo.computeVertexNormals(); @@ -1013,7 +1014,7 @@

🎨 Filament Colours

resetCamera();['bcs','ts','bs','cs','ps','orientSec'].forEach(id=>$(id).style.display=''); $('hh').style.display='';$('vpUndo').style.display='';$('ua').classList.add('hf');$('ua').querySelector('p').textContent=name; $('sf').textContent=nf.toLocaleString()+' faces'+(faceColors?' (colored)':''); - $('dl-overlay').style.display='none';$('reprocess-overlay').style.display='none';$('linf').style.display='none';S.processed=false; + $('dl-overlay').style.display='none';$('reprocess-overlay').style.display='none';$('linf').style.display='none';S.processed=false;clearExportPreview(); // Reset photo tool state S.photoImg=null;S.photoCtx=null;S.photoSel=new Set();S.photoFaces=new Set();hidePhotoOverlay(); $('photoControls').style.display='none';$('photoUploadLabel').textContent='Click to load a JPG or PNG';$('photoFileIn').value=''; @@ -1025,10 +1026,10 @@

🎨 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=>{loadSelectedFiles(e.target.files);}); 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';loadSelectedFiles(e.dataTransfer.files);}); function showLoading(msg){ $('loadAnim').style.display='block';$('pbarWrap').style.display='none'; $('pt').textContent=msg||'Loading...';$('po').classList.add('vis'); @@ -1037,7 +1038,133 @@

🎨 Filament Colours

function hideLoading(){ setTimeout(()=>{$('loadAnim').style.display='none';$('pbarWrap').style.display='';$('po').classList.remove('vis');},200); } +async function readFileBundle(fileList){ + const files=[...fileList].filter(f=>/\.(stl|3mf|obj|mtl|jpe?g|png|tiff?)$/i.test(f.name)); + const out={}; + await Promise.all(files.map(f=>new Promise((res,rej)=>{ + const r=new FileReader();r.onerror=()=>rej(r.error);r.onload=()=>{out[f.name]=r.result;res();}; + if(/\.(obj|mtl)$/i.test(f.name))r.readAsText(f);else r.readAsArrayBuffer(f); + }))); + return out; +} +async function loadSelectedFiles(fileList){ + const files=[...fileList];if(!files.length)return; + const obj=files.find(f=>/\.obj$/i.test(f.name)); + const single=files.length===1?files[0]:null; + try{ + if(obj){showLoading('Loading OBJ bundle...');const bundle=await readFileBundle(files);await loadOBJBundle(bundle,obj.name);return;} + if(single&&/\.(stl|3mf)$/i.test(single.name)){ + const r=new FileReader();r.onload=ev=>loadFile(ev.target.result,single.name);r.readAsArrayBuffer(single);return; + } + alert('Please select/drop an STL, 3MF, or an OBJ plus its .MTL and texture images.'); + }catch(e){hideLoading();alert('Error loading files: '+e.message);console.error(e);} +} 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);} + +// === OBJ + MTL + texture importer =========================================== +function normName(n){return (n||'').split(/[\\/]/).pop().toLowerCase();} +function parseMTL(txt){ + const mats={};let cur=null; + for(const raw of txt.split(/\r?\n/)){ + const line=raw.trim();if(!line||line[0]==='#')continue; + const sp=line.split(/\s+/),cmd=sp.shift().toLowerCase(),rest=sp.join(' '); + if(cmd==='newmtl'){cur={name:rest,kd:[0.8,0.8,0.8],mapKd:null};mats[rest]=cur;} + else if(cur&&cmd==='kd'&&sp.length>=3)cur.kd=[+sp[0],+sp[1],+sp[2]].map(v=>Number.isFinite(v)?Math.max(0,Math.min(1,v)):0.8); + else if(cur&&cmd==='map_kd'){ + const toks=rest.match(/(?:"[^"]+"|'[^']+'|\S+)/g)||[]; + cur.mapKd=(toks[toks.length-1]||'').replace(/^['"]|['"]$/g,''); + } + } + return mats; +} +function parseOBJ(txt){ + const v=[],vt=[],tris=[];let mat='';const mtllibs=[]; + const idx=(i,len)=>{const n=parseInt(i,10);return n>0?n-1:len+n;}; + for(const raw of txt.split(/\r?\n/)){ + const line=raw.trim();if(!line||line[0]==='#')continue; + const sp=line.split(/\s+/),cmd=sp.shift().toLowerCase(); + if(cmd==='v'&&sp.length>=3)v.push([+sp[0],+sp[1],+sp[2]]); + else if(cmd==='vt'&&sp.length>=2)vt.push([+sp[0],+sp[1]]); + else if(cmd==='usemtl')mat=sp.join(' '); + else if(cmd==='mtllib')mtllibs.push(sp.join(' ')); + else if(cmd==='f'&&sp.length>=3){ + const verts=sp.map(tok=>{const p=tok.split('/');return{vi:idx(p[0],v.length),ti:p[1]?idx(p[1],vt.length):-1};}); + for(let i=1;ib); + return new Promise((res,rej)=>{const url=URL.createObjectURL(blob),img=new Image();img.onload=()=>{URL.revokeObjectURL(url);res(img);};img.onerror=()=>{URL.revokeObjectURL(url);rej(new Error('Browser could not decode image'));};img.src=url;}); +} +async function loadTextureCanvas(buf,name){ + const ext=(name.match(/\.([^.]+)$/)||[])[1]?.toLowerCase(); + const mime=ext==='jpg'||ext==='jpeg'?'image/jpeg':ext==='png'?'image/png':ext==='tif'||ext==='tiff'?'image/tiff':'image/*'; + const bmp=await loadBitmapFromBuffer(buf,mime); + const c=document.createElement('canvas');c.width=bmp.width;c.height=bmp.height; + const x=c.getContext('2d',{willReadFrequently:true});x.drawImage(bmp,0,0); + return{canvas:c,ctx:x,w:c.width,h:c.height}; +} +function sampleTex(tex,u,v){ + if(!tex)return null; + u=((u%1)+1)%1;v=((v%1)+1)%1; + const x=Math.max(0,Math.min(tex.w-1,Math.floor(u*tex.w))); + const y=Math.max(0,Math.min(tex.h-1,Math.floor((1-v)*tex.h))); + const d=tex.ctx.getImageData(x,y,1,1).data; + return[d[0]/255,d[1]/255,d[2]/255,d[3]/255]; +} +async function loadOBJBundle(bundle,objName){ + try{ + setLoadMsg('Parsing OBJ...');await sl(0); + const obj=parseOBJ(bundle[objName]); + if(!obj.tris.length)throw new Error('No faces found in OBJ.'); + + setLoadMsg('Parsing materials...');await sl(0); + let mats={}; + for(const mtlRef of obj.mtllibs){ + const key=Object.keys(bundle).find(k=>normName(k)===normName(mtlRef)); + if(key)Object.assign(mats,parseMTL(bundle[key])); + } + + const texCache={}; + for(const m of Object.values(mats))if(m.mapKd){ + const key=Object.keys(bundle).find(k=>normName(k)===normName(m.mapKd)); + if(key){ + try{setLoadMsg('Loading texture '+key+'...');await sl(0);texCache[m.mapKd]=await loadTextureCanvas(bundle[key],key);} + catch(e){console.warn('Texture skipped:',key,e);} + } + } + + setLoadMsg('Building OBJ mesh...');await sl(0); + const pos=new Float32Array(obj.tris.length*9),fc=new Float32Array(obj.tris.length*3); + const src={kind:'obj-texture',tris:[],hasTexture:false}; + for(let fi=0;fi0.02){r+=smp[0];g+=smp[1];b+=smp[2];n++;}} + if(n)col=[r/n,g/n,b/n]; + } + srcTri.flatColor=[col[0],col[1],col[2]]; + src.tris.push(srcTri); + fc[fi*3]=col[0];fc[fi*3+1]=col[1];fc[fi*3+2]=col[2]; + } + await loadModelData(pos,obj.tris.length,objName.replace(/\.obj$/i,''),fc,null,null,src.hasTexture?src:null); + }catch(e){hideLoading();alert('Error loading OBJ: '+e.message);console.error(e);} +} + 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 const my=-((ev.clientY-rect.top)/rect.height)*2+1; syncCam(); const r2=new THREE.Raycaster();r2.setFromCamera(new THREE.Vector2(mx,my),cam); - const h=S.mesh?r2.intersectObject(S.mesh):[]; + const target=(S.exportPreview&&S.exportPreviewMesh)?S.exportPreviewMesh:S.mesh; + const h=target?r2.intersectObject(target):[]; if(h.length) return getBrushPxAt(h[0].point); return getBrushPxAt(O.t); } -function paint(ev){if(!S.mesh)return;const rect=vp.getBoundingClientRect();mo.x=((ev.clientX-rect.left)/rect.width)*2-1;mo.y=-((ev.clientY-rect.top)/rect.height)*2+1;rc.setFromCamera(mo,cam);const hits=rc.intersectObject(S.mesh);if(!hits.length)return;const fi=gfi(hits[0]);if(fi<0||fi>=S.fc.length/3)return; +function paint(ev){if(!S.mesh)return;const rect=vp.getBoundingClientRect();mo.x=((ev.clientX-rect.left)/rect.width)*2-1;mo.y=-((ev.clientY-rect.top)/rect.height)*2+1;rc.setFromCamera(mo,cam); +const isPreviewRecolor=S.exportPreview&&(S.tool==='recolor-brush'||S.tool==='recolor-sphere'); +const target=isPreviewRecolor?S.exportPreviewMesh:S.mesh; +const hits=target?rc.intersectObject(target):[];if(!hits.length)return;const fi=gfi(hits[0]); + +// Export Preview tools operate only on the processed/export mesh. +if(isPreviewRecolor){ + if(!S.exportPreviewMesh||!S.exportSolid){showToast('Process a solid-colour export first.','πŸ‘οΈ');S.painting=false;return;} + if(fi<0||fi>=S.exportSolid.triData.length)return; + if(S.tool==='recolor-brush')bRecolorPreview(hits[0].point,fi); + else bRecolorPreviewSphere(hits[0].point); + return; +} + +// In Export Preview mode, do not allow standard paint tools to modify the base model. +if(S.exportPreview){ + S.painting=false; + showToast('Switch off Export Preview to use the normal paint tools, or use Recolour Brush/Sphere here.','πŸ‘οΈ'); + return; +} + +if(fi<0||fi>=S.fc.length/3)return; if(S.tool==='eyedropper'){S.col.setRGB(S.fc[fi*3],S.fc[fi*3+1],S.fc[fi*3+2]);updCD();const hsv=r2h(S.col.r*255,S.col.g*255,S.col.b*255);S.hue=hsv[0]*360;S.sat=hsv[1];S.val=hsv[2];$('hs').value=S.hue;drawPk();S.painting=false;return;} if(S.tool==='orient'){orientToFace(fi);S.painting=false;return;} if(S.tool==='gradient'){ @@ -1705,8 +1854,23 @@

🎨 Filament Colours

} if(S.tex)S.tex.needsUpdate=true; // was missing β€” sphere brush appeared to do nothing } +function bRecolor(pt,cfi){ + const pa=S.geo.getAttribute('position').array,r=S.brushR,vis=new Set([cfi]),q=[cfi];mfRecolor(cfi); + const snx=S.fn2[cfi*3],sny=S.fn2[cfi*3+1],snz=S.fn2[cfi*3+2]; + while(q.length){ + const fi=q.shift(),nb=S.adj.get(fi);if(!nb)continue; + for(const n of nb){ + if(vis.has(n))continue;vis.add(n); + if(S.fn2[n*3]*snx+S.fn2[n*3+1]*sny+S.fn2[n*3+2]*snz<0)continue; + const i9=n*9; + const cx=(pa[i9]+pa[i9+3]+pa[i9+6])/3,cy=(pa[i9+1]+pa[i9+4]+pa[i9+7])/3,cz=(pa[i9+2]+pa[i9+5]+pa[i9+8])/3; + if(Math.sqrt((cx-pt.x)**2+(cy-pt.y)**2+(cz-pt.z)**2)<=r){mfRecolor(n);q.push(n);} + } + } + if(S.tex)S.tex.needsUpdate=true; +} function fillF(sfi){if(S.fillMode==='connected'){const sr=S.fc[sfi*3],sg=S.fc[sfi*3+1],sb=S.fc[sfi*3+2],vis=new Set([sfi]),q=[sfi];while(q.length){const fi=q.shift();if(Math.abs(S.fc[fi*3]-sr)<.01&&Math.abs(S.fc[fi*3+1]-sg)<.01&&Math.abs(S.fc[fi*3+2]-sb)<.01){mf(fi);const nb=S.adj.get(fi);if(nb)for(const n of nb)if(!vis.has(n)){vis.add(n);q.push(n);}}}}else{for(const fi of computeAngleFill(sfi))mf(fi);}} -function commitU(){if(!pend.size)return;const e={ch:new Map()};for(const[fi,info]of pend)e.ch.set(fi,{oc:info.oc,nc:[S.fc[fi*3],S.fc[fi*3+1],S.fc[fi*3+2]],wp:info.wp,ip:S.painted.has(fi)});S.us.push(e);if(S.us.length>40)S.us.shift();S.rs=[];pend.clear();updUB();if(S.processed){S.processed=false;$('dl-overlay').style.display='none';$('reprocess-overlay').style.display='block';}} +function commitU(){if(!pend.size)return;const e={ch:new Map()};for(const[fi,info]of pend)e.ch.set(fi,{oc:info.oc,nc:[S.fc[fi*3],S.fc[fi*3+1],S.fc[fi*3+2]],wp:info.wp,ip:S.painted.has(fi)});S.us.push(e);if(S.us.length>40)S.us.shift();S.rs=[];pend.clear();updUB();if(S.processed){S.processed=false;$('dl-overlay').style.display='none';$('reprocess-overlay').style.display='block';clearExportPreview();}} function applyUR(e,rev){const br=S.base.r,bg=S.base.g,bb=S.base.b;for(const[fi,info]of e.ch){const c=rev?info.oc:info.nc,p=rev?info.wp:info.ip;if(p){S.fc[fi*3]=c[0];S.fc[fi*3+1]=c[1];S.fc[fi*3+2]=c[2];S.painted.add(fi);}else{S.painted.delete(fi);S.fc[fi*3]=br;S.fc[fi*3+1]=bg;S.fc[fi*3+2]=bb;}}rebuildTexCanvas();} function undo(){ if(!S.us.length)return; @@ -1753,9 +1917,61 @@

🎨 Filament Colours

} function updUB(){$('ub').disabled=!S.us.length;$('rb').disabled=!S.rs.length;} document.addEventListener('keydown',e=>{const mod=e.metaKey||e.ctrlKey;if(mod&&e.key==='z'){e.preventDefault();undo();}if(mod&&e.key==='y'){e.preventDefault();redo();}if(mod&&e.key>='1'&&e.key<='6'){e.preventDefault();setView(['front','back','left','right','top','bottom'][+e.key-1]);}}); -document.querySelectorAll('.tb').forEach(btn=>btn.addEventListener('click',()=>{document.querySelectorAll('.tb').forEach(b=>b.classList.remove('a'));btn.classList.add('a');$('orientBtn').classList.remove('a');$('scaleBtn').classList.remove('a');hideScaleUI();S.tool=btn.dataset.tool;const names={brush:'Brush','sphere-brush':'Sphere Brush',fill:'Fill',eyedropper:'Colour Picker',gradient:'Gradient',photo:'Photo'};$('st').textContent='Tool: '+(names[S.tool]||S.tool);if(S.tool==='gradient'){$('fo').style.display='none';$('br-wrap').style.display='none';$('gradPanel').style.display='block';$('cs').style.display='none';$('photoPanel').style.display='none';clearPhotoSel();hidePhotoOverlay();updGradSubTools();updGradArrow();}else if(S.tool==='photo'){$('fo').style.display='none';$('br-wrap').style.display='none';$('gradPanel').style.display='none';$('cs').style.display='none';$('photoPanel').style.display='';clearGradSel();clearGradArrow();updPhotoSubTools();if(S.photoImg)showPhotoOverlay();}else{const _bs=$('bs');if(_bs){_bs.appendChild($('br-wrap'));_bs.appendChild($('fo'));}$('fo').style.display=S.tool==='fill'?'':'none';$('br-wrap').style.display=(S.tool==='brush'||S.tool==='sphere-brush')?'':'none';$('gradPanel').style.display='none';$('photoPanel').style.display='none';$('cs').style.display='';clearGradSel();clearPhotoSel();clearGradArrow();hidePhotoOverlay();}})); +function isExportTool(t){return t==='recolor-brush'||t==='recolor-sphere';} +function setActiveTool(tool){ + // Keep normal paint tools and export-preview recolour tools in their own modes. + if(S.exportPreview&&!isExportTool(tool))tool='recolor-brush'; + if(!S.exportPreview&&isExportTool(tool))tool='brush'; + + document.querySelectorAll('.tb').forEach(b=>b.classList.remove('a')); + const btn=document.querySelector('.tb[data-tool="'+tool+'"]'); + if(btn)btn.classList.add('a'); + if($('orientBtn'))$('orientBtn').classList.remove('a'); + if($('scaleBtn'))$('scaleBtn').classList.remove('a'); + hideScaleUI(); + + S.tool=tool; + const names={brush:'Brush','sphere-brush':'Sphere Brush',fill:'Fill',eyedropper:'Colour Picker',gradient:'Gradient',photo:'Photo','recolor-brush':'Recolour Brush','recolor-sphere':'Recolour Sphere'}; + $('st').textContent='Tool: '+(names[S.tool]||S.tool); + + if(S.tool==='gradient'){ + $('fo').style.display='none';$('br-wrap').style.display='none';$('recolorPanel').style.display='none'; + $('gradPanel').style.display='block';$('cs').style.display='none';$('photoPanel').style.display='none'; + clearPhotoSel();hidePhotoOverlay();updGradSubTools();updGradArrow(); + }else if(S.tool==='photo'){ + $('fo').style.display='none';$('br-wrap').style.display='none';$('recolorPanel').style.display='none'; + $('gradPanel').style.display='none';$('cs').style.display='none';$('photoPanel').style.display=''; + clearGradSel();clearGradArrow();updPhotoSubTools();if(S.photoImg)showPhotoOverlay(); + }else{ + const _bs=$('bs');if(_bs){_bs.appendChild($('br-wrap'));_bs.appendChild($('fo'));_bs.appendChild($('recolorPanel'));} + $('fo').style.display=S.tool==='fill'?'':'none'; + $('br-wrap').style.display=(S.tool==='brush'||S.tool==='sphere-brush'||S.tool==='recolor-brush'||S.tool==='recolor-sphere')?'':'none'; + $('recolorPanel').style.display=isExportTool(S.tool)?'':'none'; + $('gradPanel').style.display='none';$('photoPanel').style.display='none'; + $('cs').style.display='';clearGradSel();clearPhotoSel();clearGradArrow();hidePhotoOverlay(); + } +} +function syncToolsForPreview(){ + if($('toolsTitle'))$('toolsTitle').textContent=S.exportPreview?'Export Preview Tools':'Paint Tools'; + if($('paintTools'))$('paintTools').style.display=S.exportPreview?'none':'grid'; + if($('exportTools'))$('exportTools').style.display=S.exportPreview?'grid':'none'; + setActiveTool(S.exportPreview?(isExportTool(S.tool)?S.tool:'recolor-brush'):(isExportTool(S.tool)?'brush':S.tool)); +} +document.querySelectorAll('.tb').forEach(btn=>btn.addEventListener('click',()=>setActiveTool(btn.dataset.tool))); + +function normHex(h){h=(h||'').trim();if(!h.startsWith('#'))h='#'+h;if(/^#[0-9a-fA-F]{3}$/.test(h))h='#'+h[1]+h[1]+h[2]+h[2]+h[3]+h[3];return /^#[0-9a-fA-F]{6}$/.test(h)?h.toUpperCase():null;} +function hexToRgbArr(h){h=normHex(h)||'#000000';return[parseInt(h.slice(1,3),16),parseInt(h.slice(3,5),16),parseInt(h.slice(5,7),16)];} +function renderCustomPalette(){const box=$('paletteColors');if(!box)return;box.innerHTML='';S.customPalette=S.customPalette.map(normHex).filter(Boolean);if(!S.customPalette.length)S.customPalette=['#000000'];S.customPalette.forEach((col,i)=>{const row=document.createElement('div');row.className='palette-row';row.innerHTML=``;box.appendChild(row);});} +function setSolidPaletteMode(mode){S.solidPaletteMode=mode==='custom'?'custom':'auto';$('solidPalAuto')?.classList.toggle('a',S.solidPaletteMode==='auto');$('solidPalCustom')?.classList.toggle('a',S.solidPaletteMode==='custom');if($('customPalettePanel'))$('customPalettePanel').style.display=S.solidPaletteMode==='custom'?'block':'none';renderCustomPalette();} +function setSolidGeometryMode(mode){S.solidGeometryMode=mode==='texture'?'texture':'faces';$('solidGeomFaces')?.classList.toggle('a',S.solidGeometryMode==='faces');$('solidGeomTexture')?.classList.toggle('a',S.solidGeometryMode==='texture');if($('solidTexturePanel'))$('solidTexturePanel').style.display=S.solidGeometryMode==='texture'?'block':'none';} +function setSolidSubdivDepth(v){S.solidSubdivDepth=Math.max(1,Math.min(5,parseInt(v)||3));const names=['','Low','Medium-Low','Medium','High','Ultra'];if($('solidSubdivV'))$('solidSubdivV').textContent=names[S.solidSubdivDepth]||('Depth '+S.solidSubdivDepth);} +function addPaletteColor(){S.customPalette.push('#0066FF');renderCustomPalette();} +function removePaletteColor(i){if(S.customPalette.length<=1){showToast('Keep at least one palette colour');return;}S.customPalette.splice(i,1);renderCustomPalette();} +function updatePaletteColor(i,val){const h=normHex(val);if(!h){showToast('Invalid hex colour');renderCustomPalette();return;}S.customPalette[i]=h;renderCustomPalette();} +function autoPaletteToCustom(){showToast('Add/edit colours manually here.');setSolidPaletteMode('custom');} + $('fmode').addEventListener('change',function(){const descs={cmy:'Standard subtractive colour mixing. Best for vibrant colours with a white base filament in the printer.',wcmy:'Adds white filament for cleaner pastels and highlights. Use when your base isn\'t white, or for better light colour accuracy. Requires 4 filament slots.',kcmy:'Adds black filament for richer darks and shadows. Avoids muddy dark tones from CMY overlap. Requires 4 filament slots.',wkcmy:'Full spectrum β€” white for highlights, black for shadows, CMY for colours. Best colour accuracy but requires 5 filament slots.','2c':'Dual nozzle mode. Pick two filament colours β€” painted surfaces are dithered between them. Great for gradients and two-tone effects.',solid:'Exports each painted face as a solid flat colour β€” no dithering, no CMY filaments. Use this to paint a model for normal multi-colour printing (e.g. in Bambu Studio with a multi-filament printer). Not for colour blending.'};$('fmDesc').textContent=descs[this.value]||'';$('twoColorPanel').style.display=this.value==='2c'?'block':'none';$('solidColPanel').style.display=this.value==='solid'?'block':'none';}); -function togWF(){S.wf=!S.wf;if(S.wfm)S.wfm.visible=S.wf;$('wfb').classList.toggle('a',S.wf);} +function togWF(){S.wf=!S.wf;if(S.wfm)S.wfm.visible=S.wf&&!S.exportPreview;if(S.exportPreviewWfm)S.exportPreviewWfm.visible=S.wf&&S.exportPreview;$('wfb').classList.toggle('a',S.wf);} function togGrid(){gridH.visible=!gridH.visible;$('grb').classList.toggle('a',gridH.visible);} function orientToFace(fi){ @@ -1917,7 +2133,13 @@

🎨 Filament Colours

function updMk(){const m=$('cpMarker');if(m){m.style.left=(S.sat*pkC.width)+'px';m.style.top=((1-S.val)*pkC.height)+'px';}const h=$('hsMarker');if(h)h.style.left=(S.hue/360*100)+'%';} function pickC(e){const r=pkC.getBoundingClientRect();S.sat=Math.max(0,Math.min(1,(e.clientX-r.left)/r.width));S.val=Math.max(0,Math.min(1,1-(e.clientY-r.top)/r.height));const rgb=h2r(S.hue/360,S.sat,S.val);S.col.setRGB(rgb[0]/255,rgb[1]/255,rgb[2]/255);updCD();updMk();} function uHue(v){S.hue=v;drawPk();const rgb=h2r(S.hue/360,S.sat,S.val);S.col.setRGB(rgb[0]/255,rgb[1]/255,rgb[2]/255);updCD();} -function updCD(){const hex='#'+S.col.getHexString();$('cc').style.background=hex;$('hi').value=hex;} +function updCD(){const hex='#'+S.col.getHexString().toUpperCase();$('cc').style.background=hex;$('hi').value=hex;if($('recolorToHex'))$('recolorToHex').textContent=hex;} +function setRecolorFrom(v){const h=normHex(v)||'#000000';S.recolorFrom=h;$('recolorFromHex').value=h;$('recolorFromPick').value=h;$('recolorFromSw').style.background=h;} +function useCurrentAsRecolorFrom(){setRecolorFrom('#'+S.col.getHexString().toUpperCase());} +function swapRecolorColors(){const from=normHex(S.recolorFrom)||'#000000';const to='#'+S.col.getHexString().toUpperCase();setRecolorFrom(to);sCH(from);} +function recolorMatchFace(fi){const rgb=hexToRgbArr(S.recolorFrom).map(x=>x/255);const dr=S.fc[fi*3]-rgb[0],dg=S.fc[fi*3+1]-rgb[1],db=S.fc[fi*3+2]-rgb[2];return Math.sqrt(dr*dr+dg*dg+db*db)<=S.recolorTol*Math.sqrt(3);} +function mfRecolor(fi){if(!recolorMatchFace(fi))return false;mf(fi);return true;} +function recolorAllMatching(){if(!S.exportPreview||!S.exportSolid||!S.exportPreviewMesh){showToast('Recolour all works on the Export Preview only. Process, then enable Export Preview.','πŸ‘οΈ');return;}const target=[Math.round(S.col.r*255),Math.round(S.col.g*255),Math.round(S.col.b*255)],palIdx=ensurePreviewPaletteColor(target);let n=0;for(let fi=0;fi🎨 Filament Colours }; } +function nearestPaletteIndexRGB8(r,g,b,palette){let minD=Infinity,best=0;for(let i=0;isampleTexRGB8(t.tex,uv));const fallback=t.flatColor||[S.base.r,S.base.g,S.base.b];const fb=nearestPaletteIndexRGB8(Math.round(fallback[0]*255),Math.round(fallback[1]*255),Math.round(fallback[2]*255),palette);const ids=samples.map(c=>c?nearestPaletteIndexRGB8(c[0],c[1],c[2],palette):fb);for(let i=1;i=0||item.d>=maxDepth){let mat=id;if(mat<0){const uvC=item.t.uv[0]&&item.t.uv[1]&&item.t.uv[2]?[(item.t.uv[0][0]+item.t.uv[1][0]+item.t.uv[2][0])/3,(item.t.uv[0][1]+item.t.uv[1][1]+item.t.uv[2][1])/3]:null;const c=uvC?sampleTexRGB8(item.t.tex,uvC):null;const fb=item.t.flatColor||[S.base.r,S.base.g,S.base.b];mat=c?nearestPaletteIndexRGB8(c[0],c[1],c[2],palette):nearestPaletteIndexRGB8(Math.round(fb[0]*255),Math.round(fb[1]*255),Math.round(fb[2]*255),palette);}out.push({p:item.t.p,m:mat});}else{for(const st of subdivideTri4(item.t))stack.push({t:st,d:item.d+1});}}processed++;if(processed%250===0){if(progressCb)progressCb(processed/src.tris.length,out.length);await sl(0);}}return out;} + +// Return an OBJ texture source whose triangle positions are taken from the current +// editable BufferGeometry. Orient/Scale mutate S.geo, while S.objSource stores +// the original OBJ positions. Texture-boundary export must reuse original UVs +// and texture handles, but current transformed vertex positions. +function objSourceWithCurrentPositions(src,pos){ + if(!src||!pos)return src; + return { + kind:src.kind, + hasTexture:src.hasTexture, + tris:src.tris.map((t,fi)=>({ + p:[[pos[fi*9],pos[fi*9+1],pos[fi*9+2]],[pos[fi*9+3],pos[fi*9+4],pos[fi*9+5]],[pos[fi*9+6],pos[fi*9+7],pos[fi*9+8]]], + uv:t.uv, + tex:t.tex, + flatColor:t.flatColor + })) + }; +} + +function buildAdjMapFromPos(pos,nf){ + const pr=1e3,vk=(x,y,z)=>String(Math.round(x*pr))+','+String(Math.round(y*pr))+','+String(Math.round(z*pr)); + const e2f=new Map(),adj=new Map(); + for(let fi=0;fi{if(previewRecolorMatch(fi)){if(setPreviewFaceColor(fi,palIdx))changed++;return true;}return false;};tryFace(cfi);const snx=fn[cfi*3],sny=fn[cfi*3+1],snz=fn[cfi*3+2];while(q.length){const fi=q.shift(),nb=adj&&adj.get(fi);if(!nb)continue;for(const n of nb){if(vis.has(n))continue;vis.add(n);if(fn[n*3]*snx+fn[n*3+1]*sny+fn[n*3+2]*snz<0)continue;const i9=n*9;const cx=(pa[i9]+pa[i9+3]+pa[i9+6])/3,cy=(pa[i9+1]+pa[i9+4]+pa[i9+7])/3,cz=(pa[i9+2]+pa[i9+5]+pa[i9+8])/3;if(Math.sqrt((cx-pt.x)**2+(cy-pt.y)**2+(cz-pt.z)**2)<=r){tryFace(n);q.push(n);}}}if(changed)updateSolid3MF();} + +function bRecolorPreviewSphere(pt){ + if(!S.exportPreviewMesh||!S.exportSolid)return; + const target=[Math.round(S.col.r*255),Math.round(S.col.g*255),Math.round(S.col.b*255)]; + const palIdx=ensurePreviewPaletteColor(target); + const pa=S.exportPreviewMesh.geometry.getAttribute('position').array,r2=S.brushR*S.brushR,nf=S.exportSolid.triData.length; + let changed=0; + for(let fi=0;fi\n';}cgXml+='\n';const xp=['\n\n\n'+cgXml+'\n\n\n'];for(let i=0;i\n');xp.push('\n\n');for(const t of triData)xp.push('\n');xp.push('\n\n\n\n\n\n\n');const enc2=new TextEncoder();const ctypes='\n\n\n\n';const rels2='\n\n\n';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}]);if($('linf'))$('linf').innerHTML='Solid Β· '+palette.length+' colours Β· '+triData.length.toLocaleString()+' tris';} + +function clearExportPreview(){ + if(S.exportPreviewMesh){sc.remove(S.exportPreviewMesh);S.exportPreviewMesh.geometry.dispose();S.exportPreviewMesh.material.dispose();S.exportPreviewMesh=null;} + if(S.exportPreviewWfm){sc.remove(S.exportPreviewWfm);S.exportPreviewWfm.geometry.dispose();S.exportPreviewWfm.material.dispose();S.exportPreviewWfm=null;} + S.exportPreview=false;S.exportPreviewAdj=null;S.exportPreviewFn=null;S.exportSolid=null;if($('epb')){$('epb').classList.remove('a');$('epb').disabled=true;} + if(S.mesh)S.mesh.visible=true;if(S.wfm)S.wfm.visible=S.wf; if(typeof syncToolsForPreview==='function')syncToolsForPreview(); +} +function setExportPreviewFromSolid(uPos,triData,palette){ + if(S.exportPreviewMesh){sc.remove(S.exportPreviewMesh);S.exportPreviewMesh.geometry.dispose();S.exportPreviewMesh.material.dispose();S.exportPreviewMesh=null;} + if(S.exportPreviewWfm){sc.remove(S.exportPreviewWfm);S.exportPreviewWfm.geometry.dispose();S.exportPreviewWfm.material.dispose();S.exportPreviewWfm=null;} + S.exportSolid={uPos:Array.from(uPos),triData:triData.map(t=>({v1:t.v1,v2:t.v2,v3:t.v3,m:t.m})),palette:palette.map(c=>c.slice())}; + const verts=new Float32Array(triData.length*9),cols=new Float32Array(triData.length*9); + for(let i=0;i🎨 Filament Colours const uniqueColors=[...uniqueMap.values()]; // [r,g,b,weight] let palette=[]; // final [r,g,b] entries - if(kTarget>0&&kTarget{const key=c.join(',');if(seen.has(key))return false;seen.add(key);return true;}); + if(!palette.length)palette=[[0,0,0]]; + te.textContent='Using custom palette ('+palette.length+' colours)...';fe.style.width='30%';await sl(30); + }else if(kTarget>0&&kTargetb2[3]-a[3]); - const cents=[[uniqueColors[0][0],uniqueColors[0][1],uniqueColors[0][2]]]; + function rgbToLab(r,g,b){ + r/=255;g/=255;b/=255; + r=r<=0.04045?r/12.92:Math.pow((r+0.055)/1.055,2.4); + g=g<=0.04045?g/12.92:Math.pow((g+0.055)/1.055,2.4); + b=b<=0.04045?b/12.92:Math.pow((b+0.055)/1.055,2.4); + let x=(r*0.4124564+g*0.3575761+b*0.1804375)/0.95047; + let y=(r*0.2126729+g*0.7151522+b*0.0721750); + let z=(r*0.0193339+g*0.1191920+b*0.9503041)/1.08883; + const f=t=>t>0.008856?Math.cbrt(t):(7.787*t+16/116); + const fx=f(x),fy=f(y),fz=f(z); + return [116*fy-16,500*(fx-fy),200*(fy-fz)]; + } + const labColors=uniqueColors.map(c=>{ + const lab=rgbToLab(c[0],c[1],c[2]); + const chroma=Math.sqrt(lab[1]*lab[1]+lab[2]*lab[2]); + // Compress area dominance but still respect genuinely large regions. + const sal=Math.pow(Math.max(1,c[3]),0.38)*(1+Math.min(2.0,chroma/55)); + return {rgb:[c[0],c[1],c[2]],lab,w:c[3],sal}; + }); + function labD2(a,b){const dl=a[0]-b[0],da=a[1]-b[1],db=a[2]-b[2];return dl*dl+da*da+db*db;} + labColors.sort((a,b)=>b.w-a.w); + const cents=[{rgb:labColors[0].rgb.slice(),lab:labColors[0].lab.slice()}]; + const used=new Set([0]); for(let i=1;imaxScore){maxScore=score;maxIdx=j;} + for(const c of cents){const d=labD2(labColors[j].lab,c.lab);if(dbestScore){bestScore=score;bestIdx=j;} } - cents.push([uniqueColors[maxIdx][0],uniqueColors[maxIdx][1],uniqueColors[maxIdx][2]]); + if(bestIdx<0)break; + used.add(bestIdx); + cents.push({rgb:labColors[bestIdx].rgb.slice(),lab:labColors[bestIdx].lab.slice()}); } - // Iterate k-means (weighted) - for(let iter=0;iter<40;iter++){ - const sums=Array.from({length:k},()=>[0,0,0,0]); - for(const [r,g,b,w] of uniqueColors){ + for(let iter=0;iter<36;iter++){ + const sums=Array.from({length:cents.length},()=>[0,0,0,0]); + for(const c of labColors){ let minD=Infinity,best=0; - for(let i=0;i0.4||Math.abs(ng-cents[i][1])>0.4||Math.abs(nb-cents[i][2])>0.4)moved=true; - cents[i]=[nr,ng,nb]; + const nlab=rgbToLab(nr,ng,nb); + if(labD2(nlab,cents[i].lab)>0.25)moved=true; + cents[i]={rgb:[nr,ng,nb],lab:nlab}; } if(!moved)break; - if(iter%10===9){await sl(0);} + if(iter%10===9)await sl(0); + } + palette=cents.map(c=>[Math.round(c.rgb[0]),Math.round(c.rgb[1]),Math.round(c.rgb[2])]); + // Keep exported material order useful: largest assigned colour groups first. + const counts=new Array(palette.length).fill(0); + for(const [r,g,b,w] of uniqueColors){ + const lab=rgbToLab(r,g,b);let minD=Infinity,best=0; + for(let i=0;i[Math.round(c[0]),Math.round(c[1]),Math.round(c[2])]); + palette=palette.map((c,i)=>({c,w:counts[i]})).sort((a,b)=>b.w-a.w).map(x=>x.c); }else{ // Full spectrum β€” one entry per unique quantized colour for(const [r,g,b] of uniqueColors)palette.push([r,g,b]); } - // Assign each face to nearest palette entry - te.textContent='Assigning '+nf.toLocaleString()+' faces to palette...';fe.style.width='50%';await sl(30); - const faceMat=new Int32Array(nf); - for(let fi=0;fi0){te.textContent='Welding '+Math.round(fi/nf*100)+'%...';await sl(0);} + // Assign geometry to palette. In Texture Boundaries mode, textured OBJ triangles + // are adaptively subdivided in UV space so the new mesh follows recoloured + // texture regions instead of forcing each original triangle to one colour. + let triData2=[],uPos=[],uid=0; + const PR=1e6; + const weldTris=async (sourceTris,label)=>{ + const vMap=new Map();uPos=[];uid=0;triData2=[]; + for(let ti=0;ti0){te.textContent=label+' '+Math.round(ti/sourceTris.length*100)+'%...';await sl(0);} + } + }; + + if(S.solidGeometryMode==='texture'&&S.objSource&&S.objSource.hasTexture){ + te.textContent='Recolouring texture + subdividing boundaries...';fe.style.width='50%';await sl(30); + const currentTextureSource=objSourceWithCurrentPositions(S.objSource,pos); + const subTris=await buildTextureBoundarySolidTriangles(currentTextureSource,palette,S.solidSubdivDepth,(pct,count)=>{ + te.textContent='Subdividing texture boundaries '+Math.round(pct*100)+'% Β· '+count.toLocaleString()+' tris'; + }); + te.textContent='Welding subdivided geometry...';fe.style.width='66%';await sl(30); + await weldTris(subTris,'Welding subdivided geometry'); + }else{ + if(S.solidGeometryMode==='texture'&&(!S.objSource||!S.objSource.hasTexture))showToast('Texture boundary mode needs a textured OBJ; using existing faces instead.','⚠️'); + te.textContent='Assigning '+nf.toLocaleString()+' faces to palette...';fe.style.width='50%';await sl(30); + const sourceTris=[]; + for(let fi=0;fi🎨 Filament Colours const ctypes='\n\n\n\n'; const rels2='\n\n\n'; 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}]); + setExportPreviewFromSolid(uPos,triData2,palette); + toggleExportPreview(true); 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'; @@ -2537,4 +2914,4 @@

🎨 Filament Colours

} - + \ No newline at end of file From b3a862f675c7a9add4853d33be2b4c6c621e215e Mon Sep 17 00:00:00 2001 From: Jordan Houri <58380524+2s0ckz@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:13:01 -0400 Subject: [PATCH 2/8] Update 0.9.3.3.html --- 0.9.3.3.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/0.9.3.3.html b/0.9.3.3.html index 7621b77..f4c106e 100644 --- a/0.9.3.3.html +++ b/0.9.3.3.html @@ -360,7 +360,7 @@

Dithered export (CMY / WCMY / KCMY / WKCMY / 2-Filament)

2 Filaments β€” dual nozzle, dithers between two chosen colours.

Solid Colours β€” No Dithering ✦

Exports each painted face as a solid flat colour. This is NOT the dithered colour-blending export β€” it's for use with normal multi-colour FDM printing. Use this if you want to paint your model and print it on a multi-filament printer (like a Bambu AMS) using standard solid filament colours, without any CMY dithering. No special slicer settings required.

-

The Palette Size option reduces all painted colours to a fixed number using diversity-aware perceptual clustering. It compresses dominance from large same-colour areas, so a model with many black shades and small blue accents will keep black and blue instead of filling the palette with several blacks.

+

The Palette Size option reduces all painted colours to a fixed number using diversity-aware perceptual clustering. Set this to match the number of filament slots in your slicer β€” e.g. choose 8 colours for an 8-slot AMS.

Re-importing into Primed3D: The Solid Colours export also works as a project save format. Export as Solid (Full Spectrum), then re-import the resulting .3mf into Primed3D to continue editing β€” all your painted colours are preserved on the faces and can be further refined with the brush, gradient, or photo tools before re-exporting as a dithered CMY version.

Filament Colours

Use the 🎨 Filament Colours button to set the actual hex codes of your filaments. This only affects how the slicer preview displays β€” the dithering ratios themselves are always computed using ideal CMY theory regardless of your specific filament brand.

@@ -2914,4 +2914,4 @@

🎨 Filament Colours

} - \ No newline at end of file + From 4270b57e71637e599d803ce7673e6f694dd9bf89 Mon Sep 17 00:00:00 2001 From: Jordan Houri <58380524+2s0ckz@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:21:00 -0400 Subject: [PATCH 3/8] Update 0.9.3.3.html Fixes to full spectrum processing of .obj textures. Added undo/redo functionality in export preview mode. --- 0.9.3.3.html | 158 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 133 insertions(+), 25 deletions(-) diff --git a/0.9.3.3.html b/0.9.3.3.html index f4c106e..53c6a59 100644 --- a/0.9.3.3.html +++ b/0.9.3.3.html @@ -415,7 +415,7 @@

Thanks for using Primed3D! πŸŽ‰

Camera Views
- +
No model loadedv0.9.3.3 Β· Terms of UseDark Mode
@@ -458,7 +458,7 @@

🎨 Filament Colours

$('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, -filHex:{c:'#00FFFF',m:'#FF00FF',y:'#FFFF00',k:'#000000',w:'#FFFFFF'},solidPaletteMode:'auto',solidGeometryMode:'faces',solidSubdivDepth:3,customPalette:['#000000','#FFFFFF','#0066FF','#FF0000'],recolorFrom:'#000000',recolorTol:0.08,exportPreview:false,exportPreviewMesh:null,exportPreviewWfm:null,exportPreviewAdj:null,exportPreviewFn:null,exportSolid:null,photoProjection:null,objSource:null}; +filHex:{c:'#00FFFF',m:'#FF00FF',y:'#FFFF00',k:'#000000',w:'#FFFFFF'},solidPaletteMode:'auto',solidGeometryMode:'faces',solidSubdivDepth:3,customPalette:['#000000','#FFFFFF','#0066FF','#FF0000'],recolorFrom:'#000000',recolorTol:0.08,exportPreview:false,exportPreviewMesh:null,exportPreviewWfm:null,exportPreviewAdj:null,exportPreviewFn:null,exportSolid:null,exportUndo:[],exportRedo:[],exportPend:null,photoProjection:null,objSource:null}; let pend=new Map(); let selPend=new Set(); // tracks faces added to grad/photo sel during current brush stroke let fillHoverFi=-1; // last face index the fill-angle preview was computed for @@ -536,7 +536,7 @@

🎨 Filament Colours

$('toolbar').addEventListener('mouseleave',()=>{mouseOverUI=false;}); $('dl-overlay').addEventListener('mouseenter',()=>{mouseOverUI=true;bcu.style.display='none';}); $('dl-overlay').addEventListener('mouseleave',()=>{mouseOverUI=false;}); -vp.addEventListener('mousedown',e=>{if(e.button===2||(e.button===0&&e.altKey)){O.rot=true;O.lx=e.clientX;O.ly=e.clientY;e.preventDefault();}else if(e.button===1||(e.button===0&&e.shiftKey)){O.pan=true;O.lx=e.clientX;O.ly=e.clientY;e.preventDefault();}else if(e.button===0&&S.mesh&&!e.altKey&&!e.shiftKey){clearFillHover();S.painting=true;pend.clear();paint(e);}}); +vp.addEventListener('mousedown',e=>{if(e.button===2||(e.button===0&&e.altKey)){O.rot=true;O.lx=e.clientX;O.ly=e.clientY;e.preventDefault();}else if(e.button===1||(e.button===0&&e.shiftKey)){O.pan=true;O.lx=e.clientX;O.ly=e.clientY;e.preventDefault();}else if(e.button===0&&S.mesh&&!e.altKey&&!e.shiftKey){clearFillHover();S.painting=true;pend.clear();if(S.exportPreview&&(S.tool==='recolor-brush'||S.tool==='recolor-sphere'))beginExportUndo();paint(e);}}); let lastMx=0,lastMy=0; vp.addEventListener('mousemove',e=>{lastMx=e.clientX;lastMy=e.clientY; const wantCursor=S.mesh&&!mouseOverUI&&( @@ -594,6 +594,8 @@

🎨 Filament Colours

if(S.us.length>40)S.us.shift(); S.rs=[];selPend.clear();updUB(); } + }else if(S.exportPreview&&(S.tool==='recolor-brush'||S.tool==='recolor-sphere')){ + commitExportUndo(); }else{ commitU(); } @@ -1084,7 +1086,7 @@

🎨 Filament Colours

const line=raw.trim();if(!line||line[0]==='#')continue; const sp=line.split(/\s+/),cmd=sp.shift().toLowerCase(); if(cmd==='v'&&sp.length>=3)v.push([+sp[0],+sp[1],+sp[2]]); - else if(cmd==='vt'&&sp.length>=2)vt.push([+sp[0],+sp[1]]); + else if(cmd==='vt'&&sp.length>=2){const tu=Number(sp[0]),tv=Number(sp[1]);vt.push([Number.isFinite(tu)?tu:0,Number.isFinite(tv)?tv:0]);} else if(cmd==='usemtl')mat=sp.join(' '); else if(cmd==='mtllib')mtllibs.push(sp.join(' ')); else if(cmd==='f'&&sp.length>=3){ @@ -1108,10 +1110,17 @@

🎨 Filament Colours

return{canvas:c,ctx:x,w:c.width,h:c.height}; } function sampleTex(tex,u,v){ - if(!tex)return null; + if(!tex||!tex.ctx)return null; + const w=Math.floor(Number(tex.w||(tex.canvas&&tex.canvas.width)||0)); + const h=Math.floor(Number(tex.h||(tex.canvas&&tex.canvas.height)||0)); + if(!Number.isFinite(w)||!Number.isFinite(h)||w<1||h<1)return null; + u=Number(u);v=Number(v); + if(!Number.isFinite(u)||!Number.isFinite(v))return null; + // OBJ UVs can be outside 0..1; wrap them, then clamp to valid integer pixels. u=((u%1)+1)%1;v=((v%1)+1)%1; - const x=Math.max(0,Math.min(tex.w-1,Math.floor(u*tex.w))); - const y=Math.max(0,Math.min(tex.h-1,Math.floor((1-v)*tex.h))); + const x=Math.max(0,Math.min(w-1,Math.trunc(u*w))); + const y=Math.max(0,Math.min(h-1,Math.trunc((1-v)*h))); + if(!Number.isFinite(x)||!Number.isFinite(y))return null; const d=tex.ctx.getImageData(x,y,1,1).data; return[d[0]/255,d[1]/255,d[2]/255,d[3]/255]; } @@ -1872,11 +1881,50 @@

🎨 Filament Colours

function fillF(sfi){if(S.fillMode==='connected'){const sr=S.fc[sfi*3],sg=S.fc[sfi*3+1],sb=S.fc[sfi*3+2],vis=new Set([sfi]),q=[sfi];while(q.length){const fi=q.shift();if(Math.abs(S.fc[fi*3]-sr)<.01&&Math.abs(S.fc[fi*3+1]-sg)<.01&&Math.abs(S.fc[fi*3+2]-sb)<.01){mf(fi);const nb=S.adj.get(fi);if(nb)for(const n of nb)if(!vis.has(n)){vis.add(n);q.push(n);}}}}else{for(const fi of computeAngleFill(sfi))mf(fi);}} function commitU(){if(!pend.size)return;const e={ch:new Map()};for(const[fi,info]of pend)e.ch.set(fi,{oc:info.oc,nc:[S.fc[fi*3],S.fc[fi*3+1],S.fc[fi*3+2]],wp:info.wp,ip:S.painted.has(fi)});S.us.push(e);if(S.us.length>40)S.us.shift();S.rs=[];pend.clear();updUB();if(S.processed){S.processed=false;$('dl-overlay').style.display='none';$('reprocess-overlay').style.display='block';clearExportPreview();}} function applyUR(e,rev){const br=S.base.r,bg=S.base.g,bb=S.base.b;for(const[fi,info]of e.ch){const c=rev?info.oc:info.nc,p=rev?info.wp:info.ip;if(p){S.fc[fi*3]=c[0];S.fc[fi*3+1]=c[1];S.fc[fi*3+2]=c[2];S.painted.add(fi);}else{S.painted.delete(fi);S.fc[fi*3]=br;S.fc[fi*3+1]=bg;S.fc[fi*3+2]=bb;}}rebuildTexCanvas();} +function beginExportUndo(){ + if(!S.exportPreview||!S.exportSolid)return; + S.exportPend={ch:new Map()}; +} +function recordExportChange(fi,oldM,newM){ + if(!S.exportPend||oldM===newM)return; + const rec=S.exportPend.ch.get(fi); + if(rec)rec.nm=newM; + else S.exportPend.ch.set(fi,{om:oldM,nm:newM}); +} +function commitExportUndo(){ + if(!S.exportPend)return; + const e=S.exportPend;S.exportPend=null; + for(const [fi,info] of Array.from(e.ch.entries()))if(info.om===info.nm)e.ch.delete(fi); + if(!e.ch.size){updUB();return;} + S.exportUndo.push(e);if(S.exportUndo.length>40)S.exportUndo.shift(); + S.exportRedo=[];updUB(); +} +function applyExportUR(e,rev){ + if(!S.exportSolid||!S.exportPreviewMesh)return; + for(const [fi,info] of e.ch){ + const m=rev?info.om:info.nm; + setPreviewFaceColor(fi,m,false); + } + updateSolid3MF();updUB(); +} +function undoExport(){ + commitExportUndo(); + if(!S.exportUndo.length)return; + const e=S.exportUndo.pop(); + applyExportUR(e,true); + S.exportRedo.push(e);updUB(); +} +function redoExport(){ + if(!S.exportRedo.length)return; + const e=S.exportRedo.pop(); + applyExportUR(e,false); + S.exportUndo.push(e);updUB(); +} function undo(){ + if(S.exportPreview){undoExport();return;} if(!S.us.length)return; const e=S.us.pop(); if(e.selType){ - // Selection undo: remove the added faces from the selection set and restore their canvas colour const sel=e.selType==='grad'?S.gradSel:S.photoSel; for(const fi of e.added){ sel.delete(fi); @@ -1895,10 +1943,10 @@

🎨 Filament Colours

S.rs.push(e);updUB(); } function redo(){ + if(S.exportPreview){redoExport();return;} if(!S.rs.length)return; const e=S.rs.pop(); if(e.selType){ - // Selection redo: re-add the faces to the selection set with highlight colour const sel=e.selType==='grad'?S.gradSel:S.photoSel; for(const fi of e.added){ sel.add(fi); @@ -1915,8 +1963,22 @@

🎨 Filament Colours

} S.us.push(e);updUB(); } -function updUB(){$('ub').disabled=!S.us.length;$('rb').disabled=!S.rs.length;} -document.addEventListener('keydown',e=>{const mod=e.metaKey||e.ctrlKey;if(mod&&e.key==='z'){e.preventDefault();undo();}if(mod&&e.key==='y'){e.preventDefault();redo();}if(mod&&e.key>='1'&&e.key<='6'){e.preventDefault();setView(['front','back','left','right','top','bottom'][+e.key-1]);}}); +function updUB(){ + const u=S.exportPreview?S.exportUndo:S.us; + const r=S.exportPreview?S.exportRedo:S.rs; + $('ub').disabled=!u.length;$('rb').disabled=!r.length; +} +document.addEventListener('keydown',e=>{ + const mod=e.metaKey||e.ctrlKey; + if(mod&&e.key.toLowerCase()==='z'){ + e.preventDefault(); + if(e.shiftKey)redo();else undo(); + }else if(mod&&e.key.toLowerCase()==='y'){ + e.preventDefault();redo(); + }else if(mod&&e.key>='1'&&e.key<='6'){ + e.preventDefault();setView(['front','back','left','right','top','bottom'][+e.key-1]); + } +}); function isExportTool(t){return t==='recolor-brush'||t==='recolor-sphere';} function setActiveTool(tool){ // Keep normal paint tools and export-preview recolour tools in their own modes. @@ -2139,7 +2201,7 @@

🎨 Filament Colours

function swapRecolorColors(){const from=normHex(S.recolorFrom)||'#000000';const to='#'+S.col.getHexString().toUpperCase();setRecolorFrom(to);sCH(from);} function recolorMatchFace(fi){const rgb=hexToRgbArr(S.recolorFrom).map(x=>x/255);const dr=S.fc[fi*3]-rgb[0],dg=S.fc[fi*3+1]-rgb[1],db=S.fc[fi*3+2]-rgb[2];return Math.sqrt(dr*dr+dg*dg+db*db)<=S.recolorTol*Math.sqrt(3);} function mfRecolor(fi){if(!recolorMatchFace(fi))return false;mf(fi);return true;} -function recolorAllMatching(){if(!S.exportPreview||!S.exportSolid||!S.exportPreviewMesh){showToast('Recolour all works on the Export Preview only. Process, then enable Export Preview.','πŸ‘οΈ');return;}const target=[Math.round(S.col.r*255),Math.round(S.col.g*255),Math.round(S.col.b*255)],palIdx=ensurePreviewPaletteColor(target);let n=0;for(let fi=0;fi🎨 Filament Colours function ensurePreviewPaletteColor(rgb8){if(!S.exportSolid)return 0;for(let i=0;i{if(previewRecolorMatch(fi)){if(setPreviewFaceColor(fi,palIdx))changed++;return true;}return false;};tryFace(cfi);const snx=fn[cfi*3],sny=fn[cfi*3+1],snz=fn[cfi*3+2];while(q.length){const fi=q.shift(),nb=adj&&adj.get(fi);if(!nb)continue;for(const n of nb){if(vis.has(n))continue;vis.add(n);if(fn[n*3]*snx+fn[n*3+1]*sny+fn[n*3+2]*snz<0)continue;const i9=n*9;const cx=(pa[i9]+pa[i9+3]+pa[i9+6])/3,cy=(pa[i9+1]+pa[i9+4]+pa[i9+7])/3,cz=(pa[i9+2]+pa[i9+5]+pa[i9+8])/3;if(Math.sqrt((cx-pt.x)**2+(cy-pt.y)**2+(cz-pt.z)**2)<=r){tryFace(n);q.push(n);}}}if(changed)updateSolid3MF();} function bRecolorPreviewSphere(pt){ @@ -2281,13 +2343,13 @@

🎨 Filament Colours

function clearExportPreview(){ if(S.exportPreviewMesh){sc.remove(S.exportPreviewMesh);S.exportPreviewMesh.geometry.dispose();S.exportPreviewMesh.material.dispose();S.exportPreviewMesh=null;} if(S.exportPreviewWfm){sc.remove(S.exportPreviewWfm);S.exportPreviewWfm.geometry.dispose();S.exportPreviewWfm.material.dispose();S.exportPreviewWfm=null;} - S.exportPreview=false;S.exportPreviewAdj=null;S.exportPreviewFn=null;S.exportSolid=null;if($('epb')){$('epb').classList.remove('a');$('epb').disabled=true;} + S.exportPreview=false;S.exportPreviewAdj=null;S.exportPreviewFn=null;S.exportSolid=null;S.exportPreviewKind=null;S.exportUndo=[];S.exportRedo=[];S.exportPend=null;if($('epb')){$('epb').classList.remove('a');$('epb').disabled=true;} if(S.mesh)S.mesh.visible=true;if(S.wfm)S.wfm.visible=S.wf; if(typeof syncToolsForPreview==='function')syncToolsForPreview(); } function setExportPreviewFromSolid(uPos,triData,palette){ if(S.exportPreviewMesh){sc.remove(S.exportPreviewMesh);S.exportPreviewMesh.geometry.dispose();S.exportPreviewMesh.material.dispose();S.exportPreviewMesh=null;} if(S.exportPreviewWfm){sc.remove(S.exportPreviewWfm);S.exportPreviewWfm.geometry.dispose();S.exportPreviewWfm.material.dispose();S.exportPreviewWfm=null;} - S.exportSolid={uPos:Array.from(uPos),triData:triData.map(t=>({v1:t.v1,v2:t.v2,v3:t.v3,m:t.m})),palette:palette.map(c=>c.slice())}; + S.exportSolid={uPos:Array.from(uPos),triData:triData.map(t=>({v1:t.v1,v2:t.v2,v3:t.v3,m:t.m})),palette:palette.map(c=>c.slice())};S.exportUndo=[];S.exportRedo=[];S.exportPend=null;updUB(); const verts=new Float32Array(triData.length*9),cols=new Float32Array(triData.length*9); for(let i=0;i🎨 Filament Colours S.exportPreviewWfm=new THREE.LineSegments(new THREE.WireframeGeometry(g),new THREE.LineBasicMaterial({color:0x222244,opacity:.25,transparent:true}));if(S.mesh){S.exportPreviewWfm.position.copy(S.mesh.position);S.exportPreviewWfm.rotation.copy(S.mesh.rotation);S.exportPreviewWfm.scale.copy(S.mesh.scale);}S.exportPreviewWfm.visible=S.exportPreview&&S.wf;sc.add(S.exportPreviewWfm); if($('epb'))$('epb').disabled=false; } +function channelPreviewPaletteFromMode(M){ + return M.ch.map(ch=>{ + let h=(ch.hex||'#CCCCCC').replace('#',''); + if(h.length>=6)h=h.slice(0,6); + if(h.length<6)h='CCCCCC'; + const n=parseInt(h,16); + return [(n>>16)&255,(n>>8)&255,n&255]; + }); +} + function toggleExportPreview(force){ if(!S.exportPreviewMesh){showToast('Process the model first to create an export preview.','πŸ‘οΈ');return;} S.exportPreview=(typeof force==='boolean')?force:!S.exportPreview; S.exportPreviewMesh.visible=S.exportPreview;if(S.exportPreviewWfm)S.exportPreviewWfm.visible=S.exportPreview&&S.wf; if(S.mesh)S.mesh.visible=!S.exportPreview;if(S.wfm)S.wfm.visible=!S.exportPreview&&S.wf; if($('epb'))$('epb').classList.toggle('a',S.exportPreview); - if(typeof syncToolsForPreview==='function')syncToolsForPreview(); + if(typeof syncToolsForPreview==='function')syncToolsForPreview();updUB(); $('st').textContent=S.exportPreview?'Tool: '+(S.tool==='recolor-sphere'?'Recolour Sphere':'Recolour Brush'):'Tool: '+(S.tool||'Brush'); } async function processModel(){ @@ -2481,6 +2553,7 @@

🎨 Filament Colours

const rels2='\n\n\n'; 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}]); setExportPreviewFromSolid(uPos,triData2,palette); + S.exportPreviewKind='solid'; toggleExportPreview(true); 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; @@ -2558,13 +2631,14 @@

🎨 Filament Colours

const M=MODES[mode]; const nCh=M.ch.length; - // Export colour sampling: always use per-face S.fc colours. - // Photo-projected faces have S.fc set to the centroid-average of the projected pixels - // (written by applyPhotoProjection). UV texture sampling was removed because the - // Export colour: non-photo faces use exact S.fc. Photo faces use the saved camera - // projection to re-sample the photo at each sub-triangle centroid β€” this gives - // per-layer colour variation on any mesh (including low-poly ones like a cube) - // without any UV texture UV↔screen mismatch. + // Export colour sampling for non-solid filament modes: + // 1) Photo-projected faces still use the saved camera projection, so photo edits + // preserve per-sub-triangle detail. + // 2) Textured OBJ faces sample the original texture directly at the sub-triangle + // centroid UV instead of using the pre-averaged per-face colour. This makes + // CMY/WCMY/KCMY/WKCMY/2C exports follow the texture image itself. + // 3) Fallback is the current per-face S.fc colour for STL/3MF, painted-only, or + // untextured/material-only geometry. const pp=S.photoProjection; // may be null if no photo was applied const hasPhoto=pp&&S.photoFaces&&S.photoFaces.size>0; // Pre-flatten saved camera matrices to plain arrays for fast inline math (no Three.js calls) @@ -2599,12 +2673,37 @@

🎨 Filament Colours

} const Q=8; + const hasObjTexture=!!(S.objSource&&S.objSource.hasTexture&&S.objSource.tris); + function quantRGB8(c){return[Math.min(255,Math.round(c[0]/Q)*Q),Math.min(255,Math.round(c[1]/Q)*Q),Math.min(255,Math.round(c[2]/Q)*Q)];} + function baryUVForFacePoint(fi,cx,cy,cz){ + const t=S.objSource&&S.objSource.tris&&S.objSource.tris[fi]; + if(!t||!t.tex||!t.uv||!t.uv[0]||!t.uv[1]||!t.uv[2])return null; + const i=fi*9; + const ax=pos[i],ay=pos[i+1],az=pos[i+2],bx=pos[i+3],by=pos[i+4],bz=pos[i+5],cx2=pos[i+6],cy2=pos[i+7],cz2=pos[i+8]; + const v0x=bx-ax,v0y=by-ay,v0z=bz-az,v1x=cx2-ax,v1y=cy2-ay,v1z=cz2-az,v2x=cx-ax,v2y=cy-ay,v2z=cz-az; + const d00=v0x*v0x+v0y*v0y+v0z*v0z,d01=v0x*v1x+v0y*v1y+v0z*v1z,d11=v1x*v1x+v1y*v1y+v1z*v1z,d20=v2x*v0x+v2y*v0y+v2z*v0z,d21=v2x*v1x+v2y*v1y+v2z*v1z; + const den=d00*d11-d01*d01;if(Math.abs(den)<1e-12)return null; + let v=(d11*d20-d01*d21)/den,w=(d00*d21-d01*d20)/den,u=1-v-w; + u=Math.max(0,Math.min(1,u));v=Math.max(0,Math.min(1,v));w=Math.max(0,Math.min(1,w)); + const sum=u+v+w||1;u/=sum;v/=sum;w/=sum; + return [t.uv[0][0]*u+t.uv[1][0]*v+t.uv[2][0]*w,t.uv[0][1]*u+t.uv[1][1]*v+t.uv[2][1]*w,t.tex]; + } + function sampleObjTextureAt(fi,cx,cy,cz){ + if(!hasObjTexture)return null; + const uvTex=baryUVForFacePoint(fi,cx,cy,cz);if(!uvTex)return null; + const smp=sampleTex(uvTex[2],uvTex[0],uvTex[1]); + if(!smp||smp[3]<=0.02)return null; + const a=smp[3],fr=S.fc[fi*3],fg=S.fc[fi*3+1],fb=S.fc[fi*3+2]; + return [Math.round((smp[0]*a+fr*(1-a))*255),Math.round((smp[1]*a+fg*(1-a))*255),Math.round((smp[2]*a+fb*(1-a))*255)]; + } function getColor(fi,cx,cy,cz){ if(hasPhoto&&pp.faces.has(fi)){ const tc=sampleSavedPhoto(cx,cy,cz); - if(tc)return[Math.min(255,Math.round(tc[0]/Q)*Q),Math.min(255,Math.round(tc[1]/Q)*Q),Math.min(255,Math.round(tc[2]/Q)*Q)]; + if(tc)return quantRGB8(tc); } - return[Math.min(255,Math.round(S.fc[fi*3]*255/Q)*Q),Math.min(255,Math.round(S.fc[fi*3+1]*255/Q)*Q),Math.min(255,Math.round(S.fc[fi*3+2]*255/Q)*Q)]; + const texC=sampleObjTextureAt(fi,cx,cy,cz); + if(texC)return quantRGB8(texC); + return quantRGB8([S.fc[fi*3]*255,S.fc[fi*3+1]*255,S.fc[fi*3+2]*255]); } // Lazy dither sequence cache @@ -2827,6 +2926,15 @@

🎨 Filament Colours

{name:'Metadata/project_settings.config',data:enc.encode(orcaProjectSettings).buffer} ]); + + // Export Preview always represents the actual exported triangle/material data. + // For CMY/WCMY/KCMY/WKCMY/2C, show the clipped layer sub-triangles + // coloured by their assigned exported filament/material slot. + te.textContent='Building export preview...';fe.style.width='96%';await sl(30); + setExportPreviewFromSolid(uPos,triData,channelPreviewPaletteFromMode(M)); + S.exportPreviewKind=mode; + toggleExportPreview(true); + fe.style.width='100%';te.textContent='Done!';await sl(400); ov.classList.remove('vis'); $('dl-overlay').style.display='block'; From c521f41d7ea651ae2f956732bbf2347be2ab656f Mon Sep 17 00:00:00 2001 From: Jordan Houri <58380524+2s0ckz@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:18:56 -0400 Subject: [PATCH 4/8] .obj file handling Implemented @davidkrammer 's improved .obj file handling while maintaining other added features. --- 0.9.3.3.html | 371 +++++++++++++++++++++++++++------------------------ 1 file changed, 200 insertions(+), 171 deletions(-) diff --git a/0.9.3.3.html b/0.9.3.3.html index 53c6a59..1d68f17 100644 --- a/0.9.3.3.html +++ b/0.9.3.3.html @@ -236,7 +236,7 @@
-
Model
πŸ“¦

Drop STL, 3MF, or OBJ bundle here or click to browse

+
Model
πŸ“¦

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

Dimensions (mm)
mm
mm
mm
%
@@ -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'},solidPaletteMode:'auto',solidGeometryMode:'faces',solidSubdivDepth:3,customPalette:['#000000','#FFFFFF','#0066FF','#FF0000'],recolorFrom:'#000000',recolorTol:0.08,exportPreview:false,exportPreviewMesh:null,exportPreviewWfm:null,exportPreviewAdj:null,exportPreviewFn:null,exportSolid:null,exportUndo:[],exportRedo:[],exportPend:null,photoProjection:null,objSource:null}; let pend=new Map(); let selPend=new Set(); // tracks faces added to grad/photo sel during current brush stroke @@ -1028,10 +1028,12 @@

🎨 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{loadSelectedFiles(e.target.files);}); +$('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';loadSelectedFiles(e.dataTransfer.files);}); +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'); @@ -1040,139 +1042,158 @@

🎨 Filament Colours

function hideLoading(){ setTimeout(()=>{$('loadAnim').style.display='none';$('pbarWrap').style.display='';$('po').classList.remove('vis');},200); } -async function readFileBundle(fileList){ - const files=[...fileList].filter(f=>/\.(stl|3mf|obj|mtl|jpe?g|png|tiff?)$/i.test(f.name)); - const out={}; - await Promise.all(files.map(f=>new Promise((res,rej)=>{ - const r=new FileReader();r.onerror=()=>rej(r.error);r.onload=()=>{out[f.name]=r.result;res();}; - if(/\.(obj|mtl)$/i.test(f.name))r.readAsText(f);else r.readAsArrayBuffer(f); - }))); - return out; +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/\.obj$/i.test(f.name)); - const single=files.length===1?files[0]:null; - try{ - if(obj){showLoading('Loading OBJ bundle...');const bundle=await readFileBundle(files);await loadOBJBundle(bundle,obj.name);return;} - if(single&&/\.(stl|3mf)$/i.test(single.name)){ - const r=new FileReader();r.onload=ev=>loadFile(ev.target.result,single.name);r.readAsArrayBuffer(single);return; - } - alert('Please select/drop an STL, 3MF, or an OBJ plus its .MTL and texture images.'); - }catch(e){hideLoading();alert('Error loading files: '+e.message);console.error(e);} -} -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);} - -// === OBJ + MTL + texture importer =========================================== -function normName(n){return (n||'').split(/[\\/]/).pop().toLowerCase();} function parseMTL(txt){ - const mats={};let cur=null; + const mats=new Map();let cur=null; for(const raw of txt.split(/\r?\n/)){ - const line=raw.trim();if(!line||line[0]==='#')continue; - const sp=line.split(/\s+/),cmd=sp.shift().toLowerCase(),rest=sp.join(' '); - if(cmd==='newmtl'){cur={name:rest,kd:[0.8,0.8,0.8],mapKd:null};mats[rest]=cur;} - else if(cur&&cmd==='kd'&&sp.length>=3)cur.kd=[+sp[0],+sp[1],+sp[2]].map(v=>Number.isFinite(v)?Math.max(0,Math.min(1,v)):0.8); - else if(cur&&cmd==='map_kd'){ - const toks=rest.match(/(?:"[^"]+"|'[^']+'|\S+)/g)||[]; - cur.mapKd=(toks[toks.length-1]||'').replace(/^['"]|['"]$/g,''); - } + 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==='newmtl'){cur={name:rest,kd:null,mapKd:null};mats.set(rest,cur);} + else if(cur&&key==='kd'){const n=rest.split(/\s+/).map(Number);if(n.length>=3)cur.kd=normColor3(n);} + else if(cur&&key==='map_kd'){const p=parseMapLine(line);if(p)cur.mapKd=p;} } return mats; } -function parseOBJ(txt){ - const v=[],vt=[],tris=[];let mat='';const mtllibs=[]; - const idx=(i,len)=>{const n=parseInt(i,10);return n>0?n-1:len+n;}; +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.trim();if(!line||line[0]==='#')continue; - const sp=line.split(/\s+/),cmd=sp.shift().toLowerCase(); - if(cmd==='v'&&sp.length>=3)v.push([+sp[0],+sp[1],+sp[2]]); - else if(cmd==='vt'&&sp.length>=2){const tu=Number(sp[0]),tv=Number(sp[1]);vt.push([Number.isFinite(tu)?tu:0,Number.isFinite(tv)?tv:0]);} - else if(cmd==='usemtl')mat=sp.join(' '); - else if(cmd==='mtllib')mtllibs.push(sp.join(' ')); - else if(cmd==='f'&&sp.length>=3){ - const verts=sp.map(tok=>{const p=tok.split('/');return{vi:idx(p[0],v.length),ti:p[1]?idx(p[1],vt.length):-1};}); - for(let i=1;i=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.vib); - return new Promise((res,rej)=>{const url=URL.createObjectURL(blob),img=new Image();img.onload=()=>{URL.revokeObjectURL(url);res(img);};img.onerror=()=>{URL.revokeObjectURL(url);rej(new Error('Browser could not decode image'));};img.src=url;}); -} -async function loadTextureCanvas(buf,name){ - const ext=(name.match(/\.([^.]+)$/)||[])[1]?.toLowerCase(); - const mime=ext==='jpg'||ext==='jpeg'?'image/jpeg':ext==='png'?'image/png':ext==='tif'||ext==='tiff'?'image/tiff':'image/*'; - const bmp=await loadBitmapFromBuffer(buf,mime); - const c=document.createElement('canvas');c.width=bmp.width;c.height=bmp.height; - const x=c.getContext('2d',{willReadFrequently:true});x.drawImage(bmp,0,0); - return{canvas:c,ctx:x,w:c.width,h:c.height}; -} -function sampleTex(tex,u,v){ - if(!tex||!tex.ctx)return null; - const w=Math.floor(Number(tex.w||(tex.canvas&&tex.canvas.width)||0)); - const h=Math.floor(Number(tex.h||(tex.canvas&&tex.canvas.height)||0)); - if(!Number.isFinite(w)||!Number.isFinite(h)||w<1||h<1)return null; - u=Number(u);v=Number(v); - if(!Number.isFinite(u)||!Number.isFinite(v))return null; - // OBJ UVs can be outside 0..1; wrap them, then clamp to valid integer pixels. - u=((u%1)+1)%1;v=((v%1)+1)%1; - const x=Math.max(0,Math.min(w-1,Math.trunc(u*w))); - const y=Math.max(0,Math.min(h-1,Math.trunc((1-v)*h))); - if(!Number.isFinite(x)||!Number.isFinite(y))return null; - const d=tex.ctx.getImageData(x,y,1,1).data; - return[d[0]/255,d[1]/255,d[2]/255,d[3]/255]; -} -async function loadOBJBundle(bundle,objName){ - try{ - setLoadMsg('Parsing OBJ...');await sl(0); - const obj=parseOBJ(bundle[objName]); - if(!obj.tris.length)throw new Error('No faces found in OBJ.'); - - setLoadMsg('Parsing materials...');await sl(0); - let mats={}; - for(const mtlRef of obj.mtllibs){ - const key=Object.keys(bundle).find(k=>normName(k)===normName(mtlRef)); - if(key)Object.assign(mats,parseMTL(bundle[key])); + if(!faces.length)throw new Error('No polygon faces found in OBJ'); + + const mats=new Map(),samplers=new Map(); + for(const mtl of mtllibs){ + const b=fileMap.get(normFileName(mtl)); + if(!b)continue; + const parsed=parseMTL(new TextDecoder().decode(b)); + for(const[k,v]of parsed)mats.set(k,v); + } + setLoadMsg('Loading OBJ textures...'); + for(const mat of mats.values()){ + if(!mat.mapKd)continue; + const texBuf=fileMap.get(normFileName(mat.mapKd)); + if(texBuf&&!samplers.has(mat.mapKd))samplers.set(mat.mapKd,await imageSampler(texBuf,mat.mapKd)); + } + + setLoadMsg('Converting OBJ colors...'); + const posArr=new Float32Array(faces.length*9),fcArr=new Float32Array(faces.length*3); + const objSource={kind:'obj-texture',tris:[],hasTexture:false}; + let hasColor=false; + for(let fi=0;fi=0?uvs[f.c[j].ti]:null;srcTri.uv.push(uv?[uv[0],uv[1]]:null); } - - const texCache={}; - for(const m of Object.values(mats))if(m.mapKd){ - const key=Object.keys(bundle).find(k=>normName(k)===normName(m.mapKd)); - if(key){ - try{setLoadMsg('Loading texture '+key+'...');await sl(0);texCache[m.mapKd]=await loadTextureCanvas(bundle[key],key);} - catch(e){console.warn('Texture skipped:',key,e);} - } + let col=null; + const mat=f.mat?mats.get(f.mat):null; + if(mat&&mat.mapKd&&samplers.has(mat.mapKd)&&f.c.every(c=>c.ti>=0&&uvs[c.ti])){ + const uv0=uvs[f.c[0].ti],uv1=uvs[f.c[1].ti],uv2=uvs[f.c[2].ti]; + srcTri.tex=samplers.get(mat.mapKd);objSource.hasTexture=true; + col=srcTri.tex.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]; } - - setLoadMsg('Building OBJ mesh...');await sl(0); - const pos=new Float32Array(obj.tris.length*9),fc=new Float32Array(obj.tris.length*3); - const src={kind:'obj-texture',tris:[],hasTexture:false}; - for(let fi=0;fi0.02){r+=smp[0];g+=smp[1];b+=smp[2];n++;}} - if(n)col=[r/n,g/n,b/n]; - } - srcTri.flatColor=[col[0],col[1],col[2]]; - src.tris.push(srcTri); - fc[fi*3]=col[0];fc[fi*3+1]=col[1];fc[fi*3+2]=col[2]; - } - await loadModelData(pos,obj.tris.length,objName.replace(/\.obj$/i,''),fc,null,null,src.hasTexture?src:null); + 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;} + srcTri.flatColor=[fcArr[fi*3],fcArr[fi*3+1],fcArr[fi*3+2]];objSource.tris.push(srcTri); + if(fi%100000===0)await sl(0); + } + await loadModelData(posArr,faces.length,objName.replace(/\.obj$/i,''),hasColor?fcArr:null,null,null,objSource.hasTexture?objSource: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);} +} 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 } } function syncToolsForPreview(){ - if($('toolsTitle'))$('toolsTitle').textContent=S.exportPreview?'Export Preview Tools':'Paint Tools'; - if($('paintTools'))$('paintTools').style.display=S.exportPreview?'none':'grid'; - if($('exportTools'))$('exportTools').style.display=S.exportPreview?'grid':'none'; - setActiveTool(S.exportPreview?(isExportTool(S.tool)?S.tool:'recolor-brush'):(isExportTool(S.tool)?'brush':S.tool)); + const canRecolor=S.exportPreview&&S.exportPreviewKind==='solid'&&!!S.exportSolid; + if($('toolsTitle'))$('toolsTitle').textContent=canRecolor?'Export Preview Tools':'Paint Tools'; + if($('paintTools'))$('paintTools').style.display=canRecolor?'none':'grid'; + if($('exportTools'))$('exportTools').style.display=canRecolor?'grid':'none'; + if(canRecolor){setActiveTool(isExportTool(S.tool)?S.tool:'recolor-brush');} + else if(isExportTool(S.tool)){setActiveTool('brush');} } document.querySelectorAll('.tb').forEach(btn=>btn.addEventListener('click',()=>setActiveTool(btn.dataset.tool))); @@ -2118,6 +2141,17 @@

🎨 Filament Colours

$('dimY').value=sy.toFixed(2); $('dimZ').value=sz.toFixed(2); } +function getModelDims(){ + if(!S.geo)return null; + S.geo.computeBoundingBox(); + const bb=S.geo.boundingBox; + 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; S.geo.computeBoundingBox(); @@ -2283,7 +2317,7 @@

🎨 Filament Colours

} function nearestPaletteIndexRGB8(r,g,b,palette){let minD=Infinity,best=0;for(let i=0;i🎨 Filament Colours S.exportPreviewWfm=new THREE.LineSegments(new THREE.WireframeGeometry(g),new THREE.LineBasicMaterial({color:0x222244,opacity:.25,transparent:true}));if(S.mesh){S.exportPreviewWfm.position.copy(S.mesh.position);S.exportPreviewWfm.rotation.copy(S.mesh.rotation);S.exportPreviewWfm.scale.copy(S.mesh.scale);}S.exportPreviewWfm.visible=S.exportPreview&&S.wf;sc.add(S.exportPreviewWfm); if($('epb'))$('epb').disabled=false; } + +function setExportPreviewFromMaterialTriangles(uPos,triData,palette){ + if(S.exportPreviewMesh){sc.remove(S.exportPreviewMesh);S.exportPreviewMesh.geometry.dispose();S.exportPreviewMesh.material.dispose();S.exportPreviewMesh=null;} + if(S.exportPreviewWfm){sc.remove(S.exportPreviewWfm);S.exportPreviewWfm.geometry.dispose();S.exportPreviewWfm.material.dispose();S.exportPreviewWfm=null;} + S.exportSolid=null;S.exportUndo=[];S.exportRedo=[];S.exportPend=null;updUB(); + const verts=new Float32Array(triData.length*9),cols=new Float32Array(triData.length*9); + for(let i=0;i{ let h=(ch.hex||'#CCCCCC').replace('#',''); @@ -2631,14 +2689,13 @@

🎨 Filament Colours

const M=MODES[mode]; const nCh=M.ch.length; - // Export colour sampling for non-solid filament modes: - // 1) Photo-projected faces still use the saved camera projection, so photo edits - // preserve per-sub-triangle detail. - // 2) Textured OBJ faces sample the original texture directly at the sub-triangle - // centroid UV instead of using the pre-averaged per-face colour. This makes - // CMY/WCMY/KCMY/WKCMY/2C exports follow the texture image itself. - // 3) Fallback is the current per-face S.fc colour for STL/3MF, painted-only, or - // untextured/material-only geometry. + // Export colour sampling: always use per-face S.fc colours. + // Photo-projected faces have S.fc set to the centroid-average of the projected pixels + // (written by applyPhotoProjection). UV texture sampling was removed because the + // Export colour: non-photo faces use exact S.fc. Photo faces use the saved camera + // projection to re-sample the photo at each sub-triangle centroid β€” this gives + // per-layer colour variation on any mesh (including low-poly ones like a cube) + // without any UV texture UV↔screen mismatch. const pp=S.photoProjection; // may be null if no photo was applied const hasPhoto=pp&&S.photoFaces&&S.photoFaces.size>0; // Pre-flatten saved camera matrices to plain arrays for fast inline math (no Three.js calls) @@ -2673,37 +2730,12 @@

🎨 Filament Colours

} const Q=8; - const hasObjTexture=!!(S.objSource&&S.objSource.hasTexture&&S.objSource.tris); - function quantRGB8(c){return[Math.min(255,Math.round(c[0]/Q)*Q),Math.min(255,Math.round(c[1]/Q)*Q),Math.min(255,Math.round(c[2]/Q)*Q)];} - function baryUVForFacePoint(fi,cx,cy,cz){ - const t=S.objSource&&S.objSource.tris&&S.objSource.tris[fi]; - if(!t||!t.tex||!t.uv||!t.uv[0]||!t.uv[1]||!t.uv[2])return null; - const i=fi*9; - const ax=pos[i],ay=pos[i+1],az=pos[i+2],bx=pos[i+3],by=pos[i+4],bz=pos[i+5],cx2=pos[i+6],cy2=pos[i+7],cz2=pos[i+8]; - const v0x=bx-ax,v0y=by-ay,v0z=bz-az,v1x=cx2-ax,v1y=cy2-ay,v1z=cz2-az,v2x=cx-ax,v2y=cy-ay,v2z=cz-az; - const d00=v0x*v0x+v0y*v0y+v0z*v0z,d01=v0x*v1x+v0y*v1y+v0z*v1z,d11=v1x*v1x+v1y*v1y+v1z*v1z,d20=v2x*v0x+v2y*v0y+v2z*v0z,d21=v2x*v1x+v2y*v1y+v2z*v1z; - const den=d00*d11-d01*d01;if(Math.abs(den)<1e-12)return null; - let v=(d11*d20-d01*d21)/den,w=(d00*d21-d01*d20)/den,u=1-v-w; - u=Math.max(0,Math.min(1,u));v=Math.max(0,Math.min(1,v));w=Math.max(0,Math.min(1,w)); - const sum=u+v+w||1;u/=sum;v/=sum;w/=sum; - return [t.uv[0][0]*u+t.uv[1][0]*v+t.uv[2][0]*w,t.uv[0][1]*u+t.uv[1][1]*v+t.uv[2][1]*w,t.tex]; - } - function sampleObjTextureAt(fi,cx,cy,cz){ - if(!hasObjTexture)return null; - const uvTex=baryUVForFacePoint(fi,cx,cy,cz);if(!uvTex)return null; - const smp=sampleTex(uvTex[2],uvTex[0],uvTex[1]); - if(!smp||smp[3]<=0.02)return null; - const a=smp[3],fr=S.fc[fi*3],fg=S.fc[fi*3+1],fb=S.fc[fi*3+2]; - return [Math.round((smp[0]*a+fr*(1-a))*255),Math.round((smp[1]*a+fg*(1-a))*255),Math.round((smp[2]*a+fb*(1-a))*255)]; - } function getColor(fi,cx,cy,cz){ if(hasPhoto&&pp.faces.has(fi)){ const tc=sampleSavedPhoto(cx,cy,cz); - if(tc)return quantRGB8(tc); + if(tc)return[Math.min(255,Math.round(tc[0]/Q)*Q),Math.min(255,Math.round(tc[1]/Q)*Q),Math.min(255,Math.round(tc[2]/Q)*Q)]; } - const texC=sampleObjTextureAt(fi,cx,cy,cz); - if(texC)return quantRGB8(texC); - return quantRGB8([S.fc[fi*3]*255,S.fc[fi*3+1]*255,S.fc[fi*3+2]*255]); + return[Math.min(255,Math.round(S.fc[fi*3]*255/Q)*Q),Math.min(255,Math.round(S.fc[fi*3+1]*255/Q)*Q),Math.min(255,Math.round(S.fc[fi*3+2]*255/Q)*Q)]; } // Lazy dither sequence cache @@ -2926,13 +2958,9 @@

🎨 Filament Colours

{name:'Metadata/project_settings.config',data:enc.encode(orcaProjectSettings).buffer} ]); - - // Export Preview always represents the actual exported triangle/material data. - // For CMY/WCMY/KCMY/WKCMY/2C, show the clipped layer sub-triangles - // coloured by their assigned exported filament/material slot. te.textContent='Building export preview...';fe.style.width='96%';await sl(30); - setExportPreviewFromSolid(uPos,triData,channelPreviewPaletteFromMode(M)); - S.exportPreviewKind=mode; + setExportPreviewFromMaterialTriangles(uPos,triData,channelPreviewPaletteFromMode(M)); + S.exportPreviewKind='dither'; toggleExportPreview(true); fe.style.width='100%';te.textContent='Done!';await sl(400); @@ -2944,7 +2972,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 a781bf8f3ead6130b4373d376a8a3a47cb5096ae Mon Sep 17 00:00:00 2001 From: Jordan Houri <58380524+2s0ckz@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:06:59 -0400 Subject: [PATCH 5/8] Update 0.9.3.3.html From f5f4fb43878d43cb689b1d4f7209668aa8ed8c0d Mon Sep 17 00:00:00 2001 From: Jordan Houri <58380524+2s0ckz@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:07:48 -0400 Subject: [PATCH 6/8] Update 0.9.3.3.html --- 0.9.3.3.html | 102 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 68 insertions(+), 34 deletions(-) diff --git a/0.9.3.3.html b/0.9.3.3.html index 1d68f17..50217ad 100644 --- a/0.9.3.3.html +++ b/0.9.3.3.html @@ -937,6 +937,72 @@

🎨 Filament Colours

await loadModelData(finalPos,nf,name.replace(/\.3mf$/i,''),hasColors&&cc>0?finalFC:null,null,null); }catch(e){alert('Error loading 3MF: '+e.message);console.error(e);} } +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 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 Date: Mon, 27 Apr 2026 00:15:01 -0400 Subject: [PATCH 7/8] Update 0.9.3.3.html --- 0.9.3.3.html | 790 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 666 insertions(+), 124 deletions(-) diff --git a/0.9.3.3.html b/0.9.3.3.html index 50217ad..191f980 100644 --- a/0.9.3.3.html +++ b/0.9.3.3.html @@ -937,6 +937,59 @@

🎨 Filament Colours

await loadModelData(finalPos,nf,name.replace(/\.3mf$/i,''),hasColors&&cc>0?finalFC:null,null,null); }catch(e){alert('Error loading 3MF: '+e.message);console.error(e);} } +async function loadModelData(posArr,nf,name,faceColors,_uvs,_texCanvas){ + S.fn=name;if(S.mesh){sc.remove(S.mesh);if(S.wfm)sc.remove(S.wfm);} + S.tex=null;S.uvs=null;S.texCtx=null;S.texW=0;S.texH=0;S.uvScale=1; + + const geo=new THREE.BufferGeometry(); + geo.setAttribute('position',new THREE.BufferAttribute(posArr,3));geo.computeVertexNormals(); + const br=S.base.r,bg=S.base.g,bb=S.base.b; + S.fc=new Float32Array(nf*3);S.painted=new Set(); + for(let i=0;i$(id).style.display=''); + $('hh').style.display='';$('vpUndo').style.display='';$('ua').classList.add('hf');$('ua').querySelector('p').textContent=name; + $('sf').textContent=nf.toLocaleString()+' faces'+(faceColors?' (colored)':''); + $('dl-overlay').style.display='none';$('reprocess-overlay').style.display='none';$('linf').style.display='none';S.processed=false; + // Reset photo tool state + S.photoImg=null;S.photoCtx=null;S.photoSel=new Set();S.photoFaces=new Set();hidePhotoOverlay(); + $('photoControls').style.display='none';$('photoUploadLabel').textContent='Click to load a JPG or PNG';$('photoFileIn').value=''; + S.us=[];S.rs=[];pend.clear();updUB();initPk();hideLoading(); +} 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); @@ -1002,60 +1055,6 @@

🎨 Filament Colours

} canTex.needsUpdate=true; } - -async function loadModelData(posArr,nf,name,faceColors,_uvs,_texCanvas,_objSource){ - S.fn=name;if(S.mesh){sc.remove(S.mesh);if(S.wfm)sc.remove(S.wfm);} - S.tex=null;S.uvs=null;S.texCtx=null;S.texW=0;S.texH=0;S.uvScale=1;S.objSource=_objSource||null; - - const geo=new THREE.BufferGeometry(); - geo.setAttribute('position',new THREE.BufferAttribute(posArr,3));geo.computeVertexNormals(); - const br=S.base.r,bg=S.base.g,bb=S.base.b; - S.fc=new Float32Array(nf*3);S.painted=new Set(); - for(let i=0;i$(id).style.display=''); - $('hh').style.display='';$('vpUndo').style.display='';$('ua').classList.add('hf');$('ua').querySelector('p').textContent=name; - $('sf').textContent=nf.toLocaleString()+' faces'+(faceColors?' (colored)':''); - $('dl-overlay').style.display='none';$('reprocess-overlay').style.display='none';$('linf').style.display='none';S.processed=false;clearExportPreview(); - // Reset photo tool state - S.photoImg=null;S.photoCtx=null;S.photoSel=new Set();S.photoFaces=new Set();hidePhotoOverlay(); - $('photoControls').style.display='none';$('photoUploadLabel').textContent='Click to load a JPG or PNG';$('photoFileIn').value=''; - S.us=[];S.rs=[];pend.clear();updUB();initPk();hideLoading(); -} function buildAdj(pos,nf){ // pr=1e3: rounds to 3 decimal places (0.001mm tolerance). // 1e4 (0.0001mm) was too tight β€” some STL exporters produce cube-face internal-diagonal @@ -1076,6 +1075,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)){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 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))];} @@ -1165,23 +1165,18 @@

🎨 Filament Colours

setLoadMsg('Converting OBJ colors...'); const posArr=new Float32Array(faces.length*9),fcArr=new Float32Array(faces.length*3); - const objSource={kind:'obj-texture',tris:[],hasTexture:false}; let hasColor=false; for(let fi=0;fi=0?uvs[f.c[j].ti]:null;srcTri.uv.push(uv?[uv[0],uv[1]]:null); } let col=null; const mat=f.mat?mats.get(f.mat):null; if(mat&&mat.mapKd&&samplers.has(mat.mapKd)&&f.c.every(c=>c.ti>=0&&uvs[c.ti])){ const uv0=uvs[f.c[0].ti],uv1=uvs[f.c[1].ti],uv2=uvs[f.c[2].ti]; - srcTri.tex=samplers.get(mat.mapKd);objSource.hasTexture=true; - col=srcTri.tex.sample(wrappedAvg3(uv0[0],uv1[0],uv2[0]),wrappedAvg3(uv0[1],uv1[1],uv2[1])); + 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])){ @@ -1190,10 +1185,27 @@

🎨 Filament Colours

} 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;} - srcTri.flatColor=[fcArr[fi*3],fcArr[fi*3+1],fcArr[fi*3+2]];objSource.tris.push(srcTri); if(fi%100000===0)await sl(0); } - await loadModelData(posArr,faces.length,objName.replace(/\.obj$/i,''),hasColor?fcArr:null,null,null,objSource.hasTexture?objSource:null); + // Preserve file29's display/load output above, but also expose the original + // OBJ UV + texture source that file28's Solid Colours > Texture Boundaries + // exporter needs. This does not affect viewport loading. + const objTris=[];let objHasTexture=false; + for(let fi=0;fi(c.ti>=0&&uvs[c.ti])?[uvs[c.ti][0],uvs[c.ti][1]]:null); + const flatColor=[fcArr[fi*3],fcArr[fi*3+1],fcArr[fi*3+2]]; + if(tex&&uv[0]&&uv[1]&&uv[2])objHasTexture=true; + objTris.push({ + p:[[posArr[i9],posArr[i9+1],posArr[i9+2]],[posArr[i9+3],posArr[i9+4],posArr[i9+5]],[posArr[i9+6],posArr[i9+7],posArr[i9+8]]], + uv, + tex, + flatColor + }); + } + S.objSource={kind:'obj',hasTexture:objHasTexture,tris:objTris}; + 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){ @@ -1334,19 +1346,27 @@

🎨 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); @@ -2069,12 +2089,10 @@

🎨 Filament Colours

} } function syncToolsForPreview(){ - const canRecolor=S.exportPreview&&S.exportPreviewKind==='solid'&&!!S.exportSolid; - if($('toolsTitle'))$('toolsTitle').textContent=canRecolor?'Export Preview Tools':'Paint Tools'; - if($('paintTools'))$('paintTools').style.display=canRecolor?'none':'grid'; - if($('exportTools'))$('exportTools').style.display=canRecolor?'grid':'none'; - if(canRecolor){setActiveTool(isExportTool(S.tool)?S.tool:'recolor-brush');} - else if(isExportTool(S.tool)){setActiveTool('brush');} + if($('toolsTitle'))$('toolsTitle').textContent=S.exportPreview?'Export Preview Tools':'Paint Tools'; + if($('paintTools'))$('paintTools').style.display=S.exportPreview?'none':'grid'; + if($('exportTools'))$('exportTools').style.display=S.exportPreview?'grid':'none'; + setActiveTool(S.exportPreview?(isExportTool(S.tool)?S.tool:'recolor-brush'):(isExportTool(S.tool)?'brush':S.tool)); } document.querySelectorAll('.tb').forEach(btn=>btn.addEventListener('click',()=>setActiveTool(btn.dataset.tool))); @@ -2175,17 +2193,6 @@

🎨 Filament Colours

$('dimY').value=sy.toFixed(2); $('dimZ').value=sz.toFixed(2); } -function getModelDims(){ - if(!S.geo)return null; - S.geo.computeBoundingBox(); - const bb=S.geo.boundingBox; - 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; S.geo.computeBoundingBox(); @@ -2351,7 +2358,24 @@

🎨 Filament Colours

} function nearestPaletteIndexRGB8(r,g,b,palette){let minD=Infinity,best=0;for(let i=0;i3?c[3]:1]; + } + if(tex.data&&tex.w&&tex.h){ + u=Math.max(0,Math.min(1,u));v=Math.max(0,Math.min(1,v)); + const x=Math.max(0,Math.min(tex.w-1,Math.round(u*(tex.w-1)))); + const y=Math.max(0,Math.min(tex.h-1,Math.round((1-v)*(tex.h-1)))); + const i=(y*tex.w+x)*4,d=tex.data; + return [d[i]/255,d[i+1]/255,d[i+2]/255,(d[i+3]===undefined?255:d[i+3])/255]; + } + return null; +} +function sampleTexRGB8(tex,uv){if(!tex||!uv)return null;const smp=sampleTex(tex,uv[0],uv[1]);if(!smp||smp[3]<=0.02)return null;return [Math.round(smp[0]*255),Math.round(smp[1]*255),Math.round(smp[2]*255)];} function mix3(a,b,t){return [a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t];} function mix2(a,b,t){return [a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t];} function makeSubTri(p0,p1,p2,uv0,uv1,uv2,tex,flatColor){return {p:[p0,p1,p2],uv:[uv0,uv1,uv2],tex,flatColor};} @@ -2426,30 +2450,6 @@

🎨 Filament Colours

S.exportPreviewWfm=new THREE.LineSegments(new THREE.WireframeGeometry(g),new THREE.LineBasicMaterial({color:0x222244,opacity:.25,transparent:true}));if(S.mesh){S.exportPreviewWfm.position.copy(S.mesh.position);S.exportPreviewWfm.rotation.copy(S.mesh.rotation);S.exportPreviewWfm.scale.copy(S.mesh.scale);}S.exportPreviewWfm.visible=S.exportPreview&&S.wf;sc.add(S.exportPreviewWfm); if($('epb'))$('epb').disabled=false; } - -function setExportPreviewFromMaterialTriangles(uPos,triData,palette){ - if(S.exportPreviewMesh){sc.remove(S.exportPreviewMesh);S.exportPreviewMesh.geometry.dispose();S.exportPreviewMesh.material.dispose();S.exportPreviewMesh=null;} - if(S.exportPreviewWfm){sc.remove(S.exportPreviewWfm);S.exportPreviewWfm.geometry.dispose();S.exportPreviewWfm.material.dispose();S.exportPreviewWfm=null;} - S.exportSolid=null;S.exportUndo=[];S.exportRedo=[];S.exportPend=null;updUB(); - const verts=new Float32Array(triData.length*9),cols=new Float32Array(triData.length*9); - for(let i=0;i{ let h=(ch.hex||'#CCCCCC').replace('#',''); @@ -2469,7 +2469,7 @@

🎨 Filament Colours

if(typeof syncToolsForPreview==='function')syncToolsForPreview();updUB(); $('st').textContent=S.exportPreview?'Tool: '+(S.tool==='recolor-sphere'?'Recolour Sphere':'Recolour Brush'):'Tool: '+(S.tool||'Brush'); } -async function processModel(){ +async function processModel_file28_solid(){ if(!S.mesh)return; clearExportPreview(); const ov=$('po');ov.classList.add('vis');const fe=$('pf'),te=$('pt'); @@ -2723,13 +2723,14 @@

🎨 Filament Colours

const M=MODES[mode]; const nCh=M.ch.length; - // Export colour sampling: always use per-face S.fc colours. - // Photo-projected faces have S.fc set to the centroid-average of the projected pixels - // (written by applyPhotoProjection). UV texture sampling was removed because the - // Export colour: non-photo faces use exact S.fc. Photo faces use the saved camera - // projection to re-sample the photo at each sub-triangle centroid β€” this gives - // per-layer colour variation on any mesh (including low-poly ones like a cube) - // without any UV texture UV↔screen mismatch. + // Export colour sampling for non-solid filament modes: + // 1) Photo-projected faces still use the saved camera projection, so photo edits + // preserve per-sub-triangle detail. + // 2) Textured OBJ faces sample the original texture directly at the sub-triangle + // centroid UV instead of using the pre-averaged per-face colour. This makes + // CMY/WCMY/KCMY/WKCMY/2C exports follow the texture image itself. + // 3) Fallback is the current per-face S.fc colour for STL/3MF, painted-only, or + // untextured/material-only geometry. const pp=S.photoProjection; // may be null if no photo was applied const hasPhoto=pp&&S.photoFaces&&S.photoFaces.size>0; // Pre-flatten saved camera matrices to plain arrays for fast inline math (no Three.js calls) @@ -2764,12 +2765,37 @@

🎨 Filament Colours

} const Q=8; + const hasObjTexture=!!(S.objSource&&S.objSource.hasTexture&&S.objSource.tris); + function quantRGB8(c){return[Math.min(255,Math.round(c[0]/Q)*Q),Math.min(255,Math.round(c[1]/Q)*Q),Math.min(255,Math.round(c[2]/Q)*Q)];} + function baryUVForFacePoint(fi,cx,cy,cz){ + const t=S.objSource&&S.objSource.tris&&S.objSource.tris[fi]; + if(!t||!t.tex||!t.uv||!t.uv[0]||!t.uv[1]||!t.uv[2])return null; + const i=fi*9; + const ax=pos[i],ay=pos[i+1],az=pos[i+2],bx=pos[i+3],by=pos[i+4],bz=pos[i+5],cx2=pos[i+6],cy2=pos[i+7],cz2=pos[i+8]; + const v0x=bx-ax,v0y=by-ay,v0z=bz-az,v1x=cx2-ax,v1y=cy2-ay,v1z=cz2-az,v2x=cx-ax,v2y=cy-ay,v2z=cz-az; + const d00=v0x*v0x+v0y*v0y+v0z*v0z,d01=v0x*v1x+v0y*v1y+v0z*v1z,d11=v1x*v1x+v1y*v1y+v1z*v1z,d20=v2x*v0x+v2y*v0y+v2z*v0z,d21=v2x*v1x+v2y*v1y+v2z*v1z; + const den=d00*d11-d01*d01;if(Math.abs(den)<1e-12)return null; + let v=(d11*d20-d01*d21)/den,w=(d00*d21-d01*d20)/den,u=1-v-w; + u=Math.max(0,Math.min(1,u));v=Math.max(0,Math.min(1,v));w=Math.max(0,Math.min(1,w)); + const sum=u+v+w||1;u/=sum;v/=sum;w/=sum; + return [t.uv[0][0]*u+t.uv[1][0]*v+t.uv[2][0]*w,t.uv[0][1]*u+t.uv[1][1]*v+t.uv[2][1]*w,t.tex]; + } + function sampleObjTextureAt(fi,cx,cy,cz){ + if(!hasObjTexture)return null; + const uvTex=baryUVForFacePoint(fi,cx,cy,cz);if(!uvTex)return null; + const smp=sampleTex(uvTex[2],uvTex[0],uvTex[1]); + if(!smp||smp[3]<=0.02)return null; + const a=smp[3],fr=S.fc[fi*3],fg=S.fc[fi*3+1],fb=S.fc[fi*3+2]; + return [Math.round((smp[0]*a+fr*(1-a))*255),Math.round((smp[1]*a+fg*(1-a))*255),Math.round((smp[2]*a+fb*(1-a))*255)]; + } function getColor(fi,cx,cy,cz){ if(hasPhoto&&pp.faces.has(fi)){ const tc=sampleSavedPhoto(cx,cy,cz); - if(tc)return[Math.min(255,Math.round(tc[0]/Q)*Q),Math.min(255,Math.round(tc[1]/Q)*Q),Math.min(255,Math.round(tc[2]/Q)*Q)]; + if(tc)return quantRGB8(tc); } - return[Math.min(255,Math.round(S.fc[fi*3]*255/Q)*Q),Math.min(255,Math.round(S.fc[fi*3+1]*255/Q)*Q),Math.min(255,Math.round(S.fc[fi*3+2]*255/Q)*Q)]; + const texC=sampleObjTextureAt(fi,cx,cy,cz); + if(texC)return quantRGB8(texC); + return quantRGB8([S.fc[fi*3]*255,S.fc[fi*3+1]*255,S.fc[fi*3+2]*255]); } // Lazy dither sequence cache @@ -2992,9 +3018,13 @@

🎨 Filament Colours

{name:'Metadata/project_settings.config',data:enc.encode(orcaProjectSettings).buffer} ]); + + // Export Preview always represents the actual exported triangle/material data. + // For CMY/WCMY/KCMY/WKCMY/2C, show the clipped layer sub-triangles + // coloured by their assigned exported filament/material slot. te.textContent='Building export preview...';fe.style.width='96%';await sl(30); - setExportPreviewFromMaterialTriangles(uPos,triData,channelPreviewPaletteFromMode(M)); - S.exportPreviewKind='dither'; + setExportPreviewFromSolid(uPos,triData,channelPreviewPaletteFromMode(M)); + S.exportPreviewKind=mode; toggleExportPreview(true); fe.style.width='100%';te.textContent='Done!';await sl(400); @@ -3006,10 +3036,522 @@

🎨 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]; - const sizeInfo=modelSizeInfoHTML(); - info.innerHTML=(sizeInfo?sizeInfo+'
':'')+'Verts: '+uid.toLocaleString()+' Β· Tris: '+triData.length.toLocaleString()+'
'+ + info.innerHTML='Verts: '+uid.toLocaleString()+' Β· Tris: '+triData.length.toLocaleString()+'
'+ M.ch.map((ch,i)=>''+ch.n[0]+':'+counts[i].toLocaleString()+'').join(' Β· ')+' Β· '+nL+' layers'; } + +async function processModel_file29_nonSolid(){ + if(!S.mesh)return; + clearExportPreview(); + const ov=$('po');ov.classList.add('vis');const fe=$('pf'),te=$('pt'); + te.textContent='Analyzing...';fe.style.width='5%';await sl(30); + const pos=S.geo.getAttribute('position').array,nf=S.fc.length/3; + const mode=$('fmode').value; + + // ── SOLID COLOURS EXPORT (no dithering) ───────────────────────────────── + if(mode==='solid'){ + const kTarget=parseInt($('solidColCount').value)||0; // 0 = full spectrum + te.textContent='Collecting colours...';fe.style.width='15%';await sl(30); + + // Build weighted unique-colour list from S.fc (all faces) + const Q8=8; + const uniqueMap=new Map(); + for(let fi=0;fi0&&kTargetb2[3]-a[3]); + const cents=[[uniqueColors[0][0],uniqueColors[0][1],uniqueColors[0][2]]]; + for(let i=1;imaxScore){maxScore=score;maxIdx=j;} + } + cents.push([uniqueColors[maxIdx][0],uniqueColors[maxIdx][1],uniqueColors[maxIdx][2]]); + } + // Iterate k-means (weighted) + for(let iter=0;iter<40;iter++){ + const sums=Array.from({length:k},()=>[0,0,0,0]); + for(const [r,g,b,w] of uniqueColors){ + let minD=Infinity,best=0; + for(let i=0;i0.4||Math.abs(ng-cents[i][1])>0.4||Math.abs(nb-cents[i][2])>0.4)moved=true; + cents[i]=[nr,ng,nb]; + } + if(!moved)break; + if(iter%10===9){await sl(0);} + } + palette=cents.map(c=>[Math.round(c[0]),Math.round(c[1]),Math.round(c[2])]); + }else{ + // Full spectrum β€” one entry per unique quantized colour + for(const [r,g,b] of uniqueColors)palette.push([r,g,b]); + } + + // Assign each face to nearest palette entry + te.textContent='Assigning '+nf.toLocaleString()+' faces to palette...';fe.style.width='50%';await sl(30); + const faceMat=new Int32Array(nf); + for(let fi=0;fi0){te.textContent='Welding '+Math.round(fi/nf*100)+'%...';await sl(0);} + } + + // Build 3MF XML + te.textContent='Building 3MF...';fe.style.width='78%';await sl(30); + let cgXml='\n'; + for(const [r,g,b] of palette)cgXml+=`\n`; + cgXml+='\n'; + const xp=['\n\n\n'+cgXml+'\n\n\n']; + for(let i=0;i\n`); + xp.push('\n\n'); + for(const t of triData2)xp.push(`\n`); + xp.push('\n\n\n\n\n\n\n'); + + te.textContent='Packaging...';fe.style.width='90%';await sl(30); + const enc2=new TextEncoder(); + const ctypes='\n\n\n\n'; + const rels2='\n\n\n'; + 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; + const sizeInfo=modelSizeInfoHTML(); + $('linf').style.display='';$('linf').innerHTML=(sizeInfo?sizeInfo+'
':'')+'Solid Β· '+palette.length+' colours Β· '+triData2.length.toLocaleString()+' tris'; + return; + } + // ───────────────────────────────────────────────────────────────────────── + + let zMn=Infinity,zMx=-Infinity; + for(let i=2;izMx)zMx=pos[i];} + const LH=0.08; + const nL=Math.max(1,Math.ceil((zMx-zMn)/LH)); + + // Filament mode configuration + // DECOMPOSE uses ideal subtractive CMY theory β€” this produces the correct layer-dithering + // ratios regardless of the exact filament gamut. Real filament colours are used ONLY for + // the 3MF colorgroup hex values so the slicer preview displays accurately. + const fh=S.filHex; + const hex4=h=>(h.replace('#','')).toUpperCase()+'FF'; + const MODES={ + cmy:{ + ch:[{n:'Cyan',hex:'#'+hex4(fh.c)},{n:'Magenta',hex:'#'+hex4(fh.m)},{n:'Yellow',hex:'#'+hex4(fh.y)}], + decompose(r,g,b){return[1-r/255,1-g/255,1-b/255];} + }, + wcmy:{ + ch:[{n:'White',hex:'#'+hex4(fh.w)},{n:'Cyan',hex:'#'+hex4(fh.c)},{n:'Magenta',hex:'#'+hex4(fh.m)},{n:'Yellow',hex:'#'+hex4(fh.y)}], + decompose(r,g,b){ + const c=1-r/255,m=1-g/255,y=1-b/255; + const k=Math.min(c,m,y); + const w=1-Math.max(c,m,y); + const k3=k/3; + return[w,c-k+k3,m-k+k3,y-k+k3]; + } + }, + kcmy:{ + ch:[{n:'Black',hex:'#'+hex4(fh.k)},{n:'Cyan',hex:'#'+hex4(fh.c)},{n:'Magenta',hex:'#'+hex4(fh.m)},{n:'Yellow',hex:'#'+hex4(fh.y)}], + decompose(r,g,b){ + const c=1-r/255,m=1-g/255,y=1-b/255; + const kMax=Math.min(c,m,y); + const chroma=Math.max(c,m,y)-kMax; + const kThresh=Math.max(0,(kMax-0.35)/0.65); + const kScale=(1-chroma*0.7)*kThresh; + const k=kMax*kScale; + return[k,c-k,m-k,y-k]; + } + }, + wkcmy:{ + ch:[{n:'White',hex:'#'+hex4(fh.w)},{n:'Black',hex:'#'+hex4(fh.k)},{n:'Cyan',hex:'#'+hex4(fh.c)},{n:'Magenta',hex:'#'+hex4(fh.m)},{n:'Yellow',hex:'#'+hex4(fh.y)}], + decompose(r,g,b){ + const c=1-r/255,m=1-g/255,y=1-b/255; + const kMax=Math.min(c,m,y); + const chroma=Math.max(c,m,y)-kMax; + const kScale=1-chroma*0.7; + const k=kMax*kScale; + const w=1-Math.max(c,m,y); + return[w,k,c-k,m-k,y-k]; + } + } + }; + // Build 2C mode dynamically from user-selected colors + const cA2=new THREE.Color(S.fil2A),cB2=new THREE.Color(S.fil2B); + const aR=Math.round(cA2.r*255),aG=Math.round(cA2.g*255),aB=Math.round(cA2.b*255); + const bR=Math.round(cB2.r*255),bG=Math.round(cB2.g*255),bB=Math.round(cB2.b*255); + MODES['2c']={ + ch:[{n:'Fil A',hex:S.fil2A+'FF'},{n:'Fil B',hex:S.fil2B+'FF'}], + decompose(r,g,b){ + // Project color onto line Aβ†’B + const dABr=bR-aR,dABg=bG-aG,dABb=bB-aB; + const dot2=dABr*dABr+dABg*dABg+dABb*dABb; + if(dot2<1)return[0.5,0.5]; // Aβ‰ˆB, split evenly + const t=Math.max(0,Math.min(1,((r-aR)*dABr+(g-aG)*dABg+(b-aB)*dABb)/dot2)); + return[1-t,t]; + } + }; + const M=MODES[mode]; + const nCh=M.ch.length; + + // Export colour sampling: always use per-face S.fc colours. + // Photo-projected faces have S.fc set to the centroid-average of the projected pixels + // (written by applyPhotoProjection). UV texture sampling was removed because the + // Export colour: non-photo faces use exact S.fc. Photo faces use the saved camera + // projection to re-sample the photo at each sub-triangle centroid β€” this gives + // per-layer colour variation on any mesh (including low-poly ones like a cube) + // without any UV texture UV↔screen mismatch. + const pp=S.photoProjection; // may be null if no photo was applied + const hasPhoto=pp&&S.photoFaces&&S.photoFaces.size>0; + // Pre-flatten saved camera matrices to plain arrays for fast inline math (no Three.js calls) + let ppVM=null,ppPM=null; + if(hasPhoto){ppVM=pp.viewMat.elements;ppPM=pp.projMat.elements;} + + function sampleSavedPhoto(cx,cy,cz){ + // Apply view matrix (world β†’ view) + const vx=ppVM[0]*cx+ppVM[4]*cy+ppVM[8]*cz+ppVM[12]; + const vy=ppVM[1]*cx+ppVM[5]*cy+ppVM[9]*cz+ppVM[13]; + const vz=ppVM[2]*cx+ppVM[6]*cy+ppVM[10]*cz+ppVM[14]; + const vw=ppVM[3]*cx+ppVM[7]*cy+ppVM[11]*cz+ppVM[15]; + // Apply projection matrix (view β†’ clip, with perspective divide) + const cpx=ppPM[0]*vx+ppPM[4]*vy+ppPM[8]*vz+ppPM[12]*vw; + const cpy=ppPM[1]*vx+ppPM[5]*vy+ppPM[9]*vz+ppPM[13]*vw; + const cpz=ppPM[2]*vx+ppPM[6]*vy+ppPM[10]*vz+ppPM[14]*vw; + const cpw=ppPM[3]*vx+ppPM[7]*vy+ppPM[11]*vz+ppPM[15]*vw; + if(Math.abs(cpw)<1e-10||cpz/cpw>1)return null; + const sx=(cpx/cpw+1)/2*pp.vpW; + const sy=(1-cpy/cpw)/2*pp.vpH; + // Map screen β†’ photo coords + const dx=sx-pp.ovCx,dy=sy-pp.ovCy; + const lx=dx*Math.cos(pp.th)+dy*Math.sin(pp.th)+pp.ovW/2; + const ly=-dx*Math.sin(pp.th)+dy*Math.cos(pp.th)+pp.ovH/2; + if(lx<0||ly<0||lx>=pp.ovW||ly>=pp.ovH)return null; + const ix=Math.max(0,Math.min(pp.imgW-1,Math.round(lx/pp.ovW*pp.imgW))); + const iy=Math.max(0,Math.min(pp.imgH-1,Math.round(ly/pp.ovH*pp.imgH))); + const pidx=(iy*pp.imgW+ix)*4; + const pa=pp.photoData[pidx+3];if(pa<128)return null; + const a=pa/255; + return[Math.round(pp.photoData[pidx]*a),Math.round(pp.photoData[pidx+1]*a),Math.round(pp.photoData[pidx+2]*a)]; + } + + const Q=8; + function getColor(fi,cx,cy,cz){ + if(hasPhoto&&pp.faces.has(fi)){ + const tc=sampleSavedPhoto(cx,cy,cz); + if(tc)return[Math.min(255,Math.round(tc[0]/Q)*Q),Math.min(255,Math.round(tc[1]/Q)*Q),Math.min(255,Math.round(tc[2]/Q)*Q)]; + } + return[Math.min(255,Math.round(S.fc[fi*3]*255/Q)*Q),Math.min(255,Math.round(S.fc[fi*3+1]*255/Q)*Q),Math.min(255,Math.round(S.fc[fi*3+2]*255/Q)*Q)]; + } + + // Lazy dither sequence cache + // Uses proportional Bresenham with deterministic tie-breaking + // for spatial coherence (similar colors β†’ similar sequences) + function getDitherSeq(r,g,b){ + const key=r+','+g+','+b; + if(colorSeqs.has(key))return{key,seq:colorSeqs.get(key)}; + const demands=M.decompose(r,g,b); + const total=demands.reduce((a,v)=>a+Math.max(0,v),0); + const seq=new Uint8Array(nL); + if(total<0.001){ + // Decompose yielded no usable demand (e.g. white in a mode with no white channel, + // or any colour the formula cancelled to zero). + // Cycle C+M+Y channels equally β€” avoids printing pure Black (KCMY ch0) or pure White (WCMY ch0). + const ci=M.ch.findIndex(ch=>ch.n==='Cyan'); + if(ci>=0){const nc=Math.min(3,nCh-ci);for(let li=0;liMath.max(0,d)/total); + const placed=new Array(nCh).fill(0); + for(let li=0;libestD){bestD=tieBreak;bestCh=ch;} + } + seq[li]=bestCh;placed[bestCh]++; + } + } + colorSeqs.set(key,seq); + return{key,seq}; + } + + // Step 1: Precompute dither sequences for non-photo faces (one colour per face, exact S.fc). + // Photo faces use per-sub-triangle colour sampling (sampleSavedPhoto), so they don't + // need a pre-computed sequence β€” getDitherSeq is called per sub-triangle in Step 2. + te.textContent='Computing dither sequences ('+mode.toUpperCase()+')...';fe.style.width='8%';await sl(30); + const colorSeqs=new Map(); + const faceColorKey=new Array(nf); + + for(let fi=0;fi=nL){ + const cz=(fzMin+fzMax)/2; + const li2=Math.max(0,Math.min(nL-1,Math.floor((cz-zMn-ZOFF)/LH))); + allTris.push({v:tri,m:seq[li2]}); + }else{ + for(let li=liStart;li<=liEnd&&li=nL){ + const li2=Math.max(0,Math.min(nL-1,Math.floor(((fzMin+fzMax)/2-zMn-ZOFF)/LH))); + subTris.push({v:tri,li:li2}); + }else{ + for(let li=liStart;li<=liEnd&&li\n'; + cgXml+='\n'; + + // Helper: convert filament index to the hex encoding used by paint_color (Orca) + // and slic3rpe:mmu_segmentation (PrusaSlicer) β€” identical encoding, different attribute names. + // filament 0 = default, no attribute. filament 1β†’"8", 2β†’"0C", 3β†’"1C", 4β†’"2C" etc. + function filamentToColorHex(fi){ + if(fi===0)return null; + const state=fi<=2?fi+1:7+(fi-3)*4; // matches stateToFilament inverse + const val=state*4; + const h=val.toString(16).toUpperCase(); + return(val>=10&&val<16)?'0'+h:h; // "8","0C","1C","2C"... matching Bambu/Orca export convention + } + + // Build XML in chunks to avoid blocking on huge string joins. + // xmlns:slic3rpe enables PrusaSlicer mmu_segmentation attributes on triangles. + // MmPaintingVersion:1 signals PrusaSlicer that this file uses per-face painting. + const xmlChunks=['\n\n1\n\n'+cgXml+'\n\n\n']; + const xmlBatch=20000; + for(let i=0;i\n'); + if(i%xmlBatch===0&&i>0){await sl(0);te.textContent='Vertices '+Math.round(i/uid*100)+'%...';} + } + xmlChunks.push('\n\n'); + te.textContent='Writing triangles...';await sl(0); + + const counts=new Array(nCh).fill(0); + for(let i=0;i\n'); + counts[t.m]++; + if(i%xmlBatch===0&&i>0){await sl(0);te.textContent='Triangles '+Math.round(i/triData.length*100)+'%...';} + } + xmlChunks.push('\n\n\n\n\n\n\n'); + + te.textContent='Encoding XML...';fe.style.width='85%';await sl(30); + // Encode XML in chunks to avoid massive string join blocking the thread + const tEnc=new TextEncoder(); + const encodedChunks=[];let totalBytes=0; + const joinBatch=5000; + for(let i=0;ich.hex.slice(0,7)); // #RRGGBB for each channel + const prusaExtruderColours=chColors6.join(';'); + const prusaNozzles=M.ch.map(()=>'0.4').join(','); + // Slic3r_PE.config: PrusaSlicer reads this for extruder colour hints and nozzle count. + const prusaConfig='; extruder_colour = '+prusaExtruderColours+'\n; filament_colour = '+prusaExtruderColours+'\n; nozzle_diameter = '+prusaNozzles+'\n'; + // Slic3r_PE_model.config: PrusaSlicer reads this to map the object's triangles to a volume. + // The volume covers all triangles (firstid=0, lastid=total-1); extruder="0" is the base slot. + const safeN=S.fn.replace(/&/g,'&').replace(/"/g,'"'); + const prusaModelConfig='\n\n \n \n \n \n \n \n \n \n \n'; + // project_settings.config: Orca/Bambu-fork reads filament_colour to name/colour each slot. + const orcaProjectSettings=JSON.stringify({filament_colour:chColors6}); + + S.mdl3mf=makeZip([ + {name:'[Content_Types].xml',data:enc.encode(contentTypes).buffer}, + {name:'_rels/.rels',data:enc.encode(rels).buffer}, + {name:'3D/3dmodel.model',data:modelBuf.buffer}, + {name:'Metadata/Slic3r_PE.config',data:enc.encode(prusaConfig).buffer}, + {name:'Metadata/Slic3r_PE_model.config',data:enc.encode(prusaModelConfig).buffer}, + {name:'Metadata/project_settings.config',data:enc.encode(orcaProjectSettings).buffer} + ]); + + // File29 owns the non-solid dither processing path, but file28 owns the + // viewport Export Preview UI. Build the preview from the exact welded + // triangles/material slots that were just written into the 3MF so CMY/WCMY/ + // KCMY/WKCMY/2C modes show immediately after processing, just like Solid mode. + te.textContent='Building export preview...';fe.style.width='96%';await sl(30); + setExportPreviewFromSolid(uPos,triData,channelPreviewPaletteFromMode(M)); + S.exportPreviewKind=mode; + toggleExportPreview(true); + + fe.style.width='100%';te.textContent='Done!';await sl(400); + ov.classList.remove('vis'); + $('dl-overlay').style.display='block'; + $('reprocess-overlay').style.display='none'; + S.processed=true; + const info=$('linf');info.style.display=''; + 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]; + 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 ensureExportPreviewAfterProcess(kind){ + if(S.exportPreviewMesh)return true; + if(!S.geo||!S.fc)return false; + const pos=S.geo.getAttribute('position').array,nf=S.fc.length/3; + const palette=[],pmap=new Map(),uPos=Array.from(pos),triData=[]; + for(let fi=0;fi Date: Mon, 27 Apr 2026 00:37:47 -0400 Subject: [PATCH 8/8] Update 0.9.3.3.html Cleaned up code --- 0.9.3.3.html | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/0.9.3.3.html b/0.9.3.3.html index 191f980..bafafb4 100644 --- a/0.9.3.3.html +++ b/0.9.3.3.html @@ -18,13 +18,11 @@ @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); :root{--bg:#0f1117;--sf:#1a1d27;--sf2:#252936;--sf3:#2f3344;--bd:#363a4a;--tx:#e4e6ef;--txd:#8b8fa3;--cy:#00d4ff;--mg:#ff2d95;--yl:#ffd600;--ac:#6c5ce7;--ag:rgba(108,92,231,.25);--ok:#2ecc71} body.light{--bg:#e8eaf0;--sf:#f4f5f8;--sf2:#eaecf2;--sf3:#dde0ea;--bd:#c4c8d8;--tx:#1a1d27;--txd:#4a5068;--cy:#0088bb;--mg:#c4005e;--yl:#8a7200;--ac:#6c5ce7;--ag:rgba(108,92,231,.15);--ok:#167a3e} -/* Header always stays dark β€” text colours identical to dark mode */ body.light .thdr{background:#1a1d27;border-bottom-color:#363a4a} body.light .thdr *{color:#8b8fa3!important} body.light .thdr .rev-orange{color:#ffd600!important} body.light .thdr a:hover,body.light .thdr button:hover{color:#e4e6ef!important;opacity:1!important} body.light #vp{background:var(--bg)} -/* Viewport overlay buttons: white bg, dark readable text */ body.light .vb{background:rgba(255,255,255,.9);color:#252d44;border-color:#b0b6cc} body.light .vb:hover{color:#1a1d27;border-color:var(--ac)} body.light .vb.a{color:#1a1d27;background:var(--ag);border-color:var(--ac)} @@ -1187,9 +1185,6 @@

🎨 Filament Colours

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); } - // Preserve file29's display/load output above, but also expose the original - // OBJ UV + texture source that file28's Solid Colours > Texture Boundaries - // exporter needs. This does not affect viewport loading. const objTris=[];let objHasTexture=false; for(let fi=0;fi🎨 Filament Colours if(typeof syncToolsForPreview==='function')syncToolsForPreview();updUB(); $('st').textContent=S.exportPreview?'Tool: '+(S.tool==='recolor-sphere'?'Recolour Sphere':'Recolour Brush'):'Tool: '+(S.tool||'Brush'); } -async function processModel_file28_solid(){ +async function processModelSolid(){ if(!S.mesh)return; clearExportPreview(); const ov=$('po');ov.classList.add('vis');const fe=$('pf'),te=$('pt'); @@ -3018,7 +3013,6 @@

🎨 Filament Colours

{name:'Metadata/project_settings.config',data:enc.encode(orcaProjectSettings).buffer} ]); - // Export Preview always represents the actual exported triangle/material data. // For CMY/WCMY/KCMY/WKCMY/2C, show the clipped layer sub-triangles // coloured by their assigned exported filament/material slot. @@ -3040,7 +3034,7 @@

🎨 Filament Colours

M.ch.map((ch,i)=>''+ch.n[0]+':'+counts[i].toLocaleString()+'').join(' Β· ')+' Β· '+nL+' layers'; } -async function processModel_file29_nonSolid(){ +async function processModelNonSolid(){ if(!S.mesh)return; clearExportPreview(); const ov=$('po');ov.classList.add('vis');const fe=$('pf'),te=$('pt'); @@ -3494,10 +3488,6 @@

🎨 Filament Colours

{name:'Metadata/project_settings.config',data:enc.encode(orcaProjectSettings).buffer} ]); - // File29 owns the non-solid dither processing path, but file28 owns the - // viewport Export Preview UI. Build the preview from the exact welded - // triangles/material slots that were just written into the 3MF so CMY/WCMY/ - // KCMY/WKCMY/2C modes show immediately after processing, just like Solid mode. te.textContent='Building export preview...';fe.style.width='96%';await sl(30); setExportPreviewFromSolid(uPos,triData,channelPreviewPaletteFromMode(M)); S.exportPreviewKind=mode; @@ -3517,7 +3507,6 @@

🎨 Filament Colours

M.ch.map((ch,i)=>''+ch.n[0]+':'+counts[i].toLocaleString()+'').join(' Β· ')+' Β· '+nL+' layers'; } - function ensureExportPreviewAfterProcess(kind){ if(S.exportPreviewMesh)return true; if(!S.geo||!S.fc)return false; @@ -3538,13 +3527,12 @@

🎨 Filament Colours

async function processModel(){ const mode=$('fmode').value; if(mode==='solid'){ - const r=await processModel_file28_solid(); + const r=await processModelSolid(); if(!S.exportPreviewMesh)ensureExportPreviewAfterProcess('solid'); if(S.exportPreviewMesh)toggleExportPreview(true); return r; } - const r=await processModel_file29_nonSolid(); - // Keep file28's preview UI aware that a processed non-solid export is available. + const r=await processModelNonSolid(); if(!S.exportPreviewMesh)ensureExportPreviewAfterProcess(mode); if(S.exportPreviewMesh)toggleExportPreview(true); if($('epb'))$('epb').disabled=!S.exportPreviewMesh;