diff --git a/0.9.3.3.html b/0.9.3.3.html index c5cd873..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)} @@ -230,18 +228,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 + MTL + texture here

Dimensions (mm)
mm
mm
mm
%
- - + +
- + β˜• Buy me a coffee
@@ -360,7 +358,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. 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.

@@ -411,11 +409,11 @@

Thanks for using Primed3D! πŸŽ‰

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

🎨 Filament Colours

-
Drop STL or 3MF file here
+
Drop STL, OBJ, MTL, texture, or 3MF file here
⚠️
Processing...
@@ -457,8 +455,8 @@

🎨 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, -filHex:{c:'#00FFFF',m:'#FF00FF',y:'#FFFF00',k:'#000000',w:'#FFFFFF'},photoProjection:null}; +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 let fillHoverFi=-1; // last face index the fill-angle preview was computed for @@ -536,11 +534,12 @@

🎨 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&&( 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){ @@ -593,6 +592,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(); } @@ -968,40 +969,8 @@

🎨 Filament Colours

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

🎨 Filament Colours

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

🎨 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,14 +1933,68 @@

🎨 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 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); @@ -1731,10 +2013,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); @@ -1751,11 +2033,77 @@

🎨 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]);}}); -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 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. + 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 +2265,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;}beginExportUndo();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 }; } -async function processModel(){ +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};} +function subdivideTri4(t){const p=t.p,uv=t.uv;const p01=mix3(p[0],p[1],0.5),p12=mix3(p[1],p[2],0.5),p20=mix3(p[2],p[0],0.5);const uv01=uv[0]&&uv[1]?mix2(uv[0],uv[1],0.5):null,uv12=uv[1]&&uv[2]?mix2(uv[1],uv[2],0.5):null,uv20=uv[2]&&uv[0]?mix2(uv[2],uv[0],0.5):null;return [makeSubTri(p[0],p01,p20,uv[0],uv01,uv20,t.tex,t.flatColor),makeSubTri(p01,p[1],p12,uv01,uv[1],uv12,t.tex,t.flatColor),makeSubTri(p20,p12,p[2],uv20,uv12,uv[2],t.tex,t.flatColor),makeSubTri(p01,p12,p20,uv01,uv12,uv20,t.tex,t.flatColor)];} +function classifyTexturedTri(t,palette){if(!t.tex||!t.uv[0]||!t.uv[1]||!t.uv[2]){const c=t.flatColor||[S.base.r,S.base.g,S.base.b];return nearestPaletteIndexRGB8(Math.round(c[0]*255),Math.round(c[1]*255),Math.round(c[2]*255),palette);}const uvC=[(t.uv[0][0]+t.uv[1][0]+t.uv[2][0])/3,(t.uv[0][1]+t.uv[1][1]+t.uv[2][1])/3];const samples=[t.uv[0],t.uv[1],t.uv[2],uvC].map(uv=>sampleTexRGB8(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;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.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('#',''); + 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();updUB(); + $('st').textContent=S.exportPreview?'Tool: '+(S.tool==='recolor-sphere'?'Recolour Sphere':'Recolour Brush'):'Tool: '+(S.tool||'Brush'); +} +async function processModelSolid(){ 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; @@ -2024,69 +2491,136 @@

🎨 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[0]),Math.round(c[1]),Math.round(c[2])]); + 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({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); + 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; $('linf').style.display='';$('linf').innerHTML='Solid Β· '+palette.length+' colours Β· '+triData2.length.toLocaleString()+' tris'; @@ -2181,13 +2718,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) @@ -2222,12 +2760,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 @@ -2450,6 +3013,14 @@

🎨 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'; @@ -2462,6 +3033,513 @@

🎨 Filament Colours

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 processModelNonSolid(){ + 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} + ]); + + 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