mirror of
https://github.com/hanzoai/examples.git
synced 2026-11-02 23:29:56 +00:00
`ex-admin`, `ex-kanban`, `ex-shop`. That is not a name, it is a filing code, and it told every visitor that what they were looking at was a sample of something rather than a thing. Nobody bookmarks Example #47. So each one is now a product: a name, a mark, a hue that drives the kit's --acc so the whole page inherits it, and a tagline in its own voice. ex-kanban is Lanes — "a board with WIP limits that mean something, because they stop you". The identity lives in ONE place (products.json) and the shell is the only way a product is built, so an unbranded page cannot be published. AND A BUILDER. Ten named agents (agents.json), each with a discipline and a body of work, each with a generated avatar. The byline reads "<Product> — published by Hanzo AI, built by <Agent>, an AI agent working on <X>". That is honest attribution, not decoration: the agent really did write the app. They are described as what they are and never dressed as people — no biography, no employer, no photograph, no testimonial. HIP-903. An agent that genuinely built the thing needs none of that, and inventing it would turn a true story into a false one. LINEAGE IS RECORDED, NOT ASSERTED. Every product's project is created by POST /v1/projects/fork from a gallery template, so projects.forked_from is a fact the platform wrote at fork time. 74/74, across 28 parent templates. AND FOR SIX OF THEM, THE BYTES ARE THE TEMPLATE'S. derive.py checks out the parent, applies the agent's edit to its SOURCE, runs the template's own build, and ships that. The diff is the point — proof the product is a derivative and proof a template is agent-editable, in one artifact. It also found what the pre-built demos were hiding: four templates did not compile at all, and several 404'd on an /api/placeholder route that cannot exist on a static host. Fixed upstream and in the edit respectively. The other 68 carry a real fork edge but ship the kit build. Said plainly in the README rather than papered over. One slug now names the source file, the project and the host. Co-authored-by: Hanzo Dev <[email protected]>
183 lines
10 KiB
HTML
183 lines
10 KiB
HTML
<!--hz {"title":"Voxel","archetype":"WebGL / 3D editor","creator":"gfx","desc":"Voxel editor in WebGL2: place and remove cubes with a real ray-picked cursor, orbit and zoom, per-face shading, a colour palette, undo, and export of the scene to a .obj mesh.","nav":[["#/","Editor"]]} -->
|
|
<div class="row" style="margin-bottom:10px">
|
|
<h1 style="margin:0">Voxel</h1><span class="tag" id="count"></span>
|
|
<span id="pal" class="row" style="gap:4px"></span>
|
|
<span class="spacer"></span>
|
|
<button id="mode" class="p">Place</button><button id="undo">Undo</button>
|
|
<button id="obj">Export .obj</button><button class="g" id="clear">Clear</button>
|
|
</div>
|
|
<div class="split r">
|
|
<div class="pane"><header>Scene <span class="spacer"></span><span class="small muted" id="fps">—</span></header>
|
|
<canvas id="gl" style="width:100%;display:block;background:#080a11;aspect-ratio:16/10;cursor:crosshair"></canvas>
|
|
<div class="body"><p class="hz-note" style="margin:0">Drag with the right button (or hold shift) to orbit, scroll to zoom, click to place or remove a cube. The cursor position is found by marching a ray through the voxel grid on the CPU — the same DDA a raycaster uses.</p></div></div>
|
|
<div class="pane"><header>Grid</header><div class="body col" id="ui"></div></div>
|
|
</div>
|
|
<script>
|
|
const {h,fill,$,store,fmt,toast,submit} = hz;
|
|
const N = 16;
|
|
const COLORS = ['#5b8cff','#8b5cf6','#3ecf8e','#f6b73c','#f4685e','#22b8cf','#e7eaf0','#6f7789'];
|
|
const START = (()=>{ const m={};
|
|
for(let x=0;x<N;x++) for(let z=0;z<N;z++) m[[x,0,z]] = 7;
|
|
for(let x=5;x<11;x++) for(let z=5;z<11;z++) for(let y=1;y<4;y++)
|
|
if(x===5||x===10||z===5||z===10||y===3) m[[x,y,z]] = (x+z+y)%3;
|
|
for(let y=1;y<6;y++) m[[3,y,3]] = 3;
|
|
m[[3,6,3]] = 4;
|
|
return m; })();
|
|
const vox = store('voxel.map', START);
|
|
const undo = [];
|
|
let color = 0, mode = 'place', yaw = 0.7, pitch = 0.55, dist = 34, drag = null, hover = null;
|
|
const cv = $('#gl'), gl = cv.getContext('webgl2', {antialias:true});
|
|
|
|
const VS = `#version 300 es
|
|
in vec3 pos; in vec3 nrm; in vec3 off; in vec3 col;
|
|
uniform mat4 MVP; out vec3 vN; out vec3 vC;
|
|
void main(){ vN=nrm; vC=col; gl_Position = MVP * vec4(pos+off,1.0); }`;
|
|
const FS = `#version 300 es
|
|
precision highp float; in vec3 vN; in vec3 vC; out vec4 O;
|
|
void main(){
|
|
vec3 l = normalize(vec3(0.5,1.0,0.35));
|
|
float d = 0.42 + 0.58*max(dot(normalize(vN), l), 0.0);
|
|
O = vec4(vC*d, 1.0);
|
|
}`;
|
|
function sh(t,s){const o=gl.createShader(t);gl.shaderSource(o,s);gl.compileShader(o);
|
|
if(!gl.getShaderParameter(o,gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(o)); return o;}
|
|
const prog=gl.createProgram();
|
|
gl.attachShader(prog,sh(gl.VERTEX_SHADER,VS)); gl.attachShader(prog,sh(gl.FRAGMENT_SHADER,FS));
|
|
gl.bindAttribLocation(prog,0,'pos'); gl.bindAttribLocation(prog,1,'nrm');
|
|
gl.bindAttribLocation(prog,2,'off'); gl.bindAttribLocation(prog,3,'col');
|
|
gl.linkProgram(prog); gl.useProgram(prog); gl.enable(gl.DEPTH_TEST);
|
|
// One unit cube, instanced per voxel.
|
|
const F = [[[0,0,1],[0,0,1]],[[0,0,-1],[0,0,-1]],[[1,0,0],[1,0,0]],[[-1,0,0],[-1,0,0]],[[0,1,0],[0,1,0]],[[0,-1,0],[0,-1,0]]];
|
|
const P=[], Nn=[];
|
|
const quad=(a,b,c,d,n)=>{ [a,b,c,a,c,d].forEach(v=>{P.push(...v); Nn.push(...n);}); };
|
|
const s=0.5;
|
|
quad([-s,-s,s],[s,-s,s],[s,s,s],[-s,s,s],[0,0,1]);
|
|
quad([s,-s,-s],[-s,-s,-s],[-s,s,-s],[s,s,-s],[0,0,-1]);
|
|
quad([s,-s,s],[s,-s,-s],[s,s,-s],[s,s,s],[1,0,0]);
|
|
quad([-s,-s,-s],[-s,-s,s],[-s,s,s],[-s,s,-s],[-1,0,0]);
|
|
quad([-s,s,s],[s,s,s],[s,s,-s],[-s,s,-s],[0,1,0]);
|
|
quad([-s,-s,-s],[s,-s,-s],[s,-s,s],[-s,-s,s],[0,-1,0]);
|
|
const vao = gl.createVertexArray(); gl.bindVertexArray(vao);
|
|
const buf = (data,loc,size,div)=>{ const b=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,b);
|
|
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(data),gl.DYNAMIC_DRAW);
|
|
gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc,size,gl.FLOAT,false,0,0);
|
|
if(div) gl.vertexAttribDivisor(loc,div); return b; };
|
|
buf(P,0,3,0); buf(Nn,1,3,0);
|
|
const offBuf = buf([],2,3,1), colBuf = buf([],3,3,1);
|
|
let instances = 0;
|
|
function upload(){
|
|
const m = vox.get(), offs=[], cols=[];
|
|
Object.entries(m).forEach(([k,ci])=>{ const [x,y,z]=k.split(',').map(Number);
|
|
offs.push(x-N/2, y, z-N/2);
|
|
const c = COLORS[ci]; cols.push(parseInt(c.slice(1,3),16)/255, parseInt(c.slice(3,5),16)/255, parseInt(c.slice(5,7),16)/255); });
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, offBuf); gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(offs),gl.DYNAMIC_DRAW);
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, colBuf); gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(cols),gl.DYNAMIC_DRAW);
|
|
instances = offs.length/3;
|
|
$('#count').textContent = instances+' voxels';
|
|
}
|
|
function mat(){
|
|
const asp = cv.width/cv.height, f=1/Math.tan(0.9/2), near=0.1, far=200, nf=1/(near-far);
|
|
const Pm=[f/asp,0,0,0, 0,f,0,0, 0,0,(far+near)*nf,-1, 0,0,2*far*near*nf,0];
|
|
const eye=[Math.cos(yaw)*Math.cos(pitch)*dist, Math.sin(pitch)*dist, Math.sin(yaw)*Math.cos(pitch)*dist];
|
|
const nrm2=v=>{const l=Math.hypot(...v)||1;return[v[0]/l,v[1]/l,v[2]/l];};
|
|
const crs=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
|
|
const dot=(a,b)=>a[0]*b[0]+a[1]*b[1]+a[2]*b[2];
|
|
const at=[0,3,0];
|
|
const zv=nrm2([eye[0]-at[0],eye[1]-at[1],eye[2]-at[2]]), xv=nrm2(crs([0,1,0],zv)), yv=crs(zv,xv);
|
|
const V=[xv[0],yv[0],zv[0],0, xv[1],yv[1],zv[1],0, xv[2],yv[2],zv[2],0,
|
|
-dot(xv,eye),-dot(yv,eye),-dot(zv,eye),1];
|
|
const o=new Float32Array(16);
|
|
for(let c=0;c<4;c++)for(let r0=0;r0<4;r0++){let s2=0;
|
|
for(let k=0;k<4;k++) s2+=Pm[k*4+r0]*V[c*4+k]; o[c*4+r0]=s2;}
|
|
return {mvp:o, eye};
|
|
}
|
|
// Ray-march the grid (3D DDA) to find the first filled cell and the face hit.
|
|
function pick(px,py){
|
|
const {eye} = mat();
|
|
const asp=cv.width/cv.height, f=1/Math.tan(0.9/2);
|
|
const ndc=[(px/cv.width*2-1)*asp/f, (1-py/cv.height*2)/f];
|
|
const nrm2=v=>{const l=Math.hypot(...v)||1;return[v[0]/l,v[1]/l,v[2]/l];};
|
|
const crs=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
|
|
const at=[0,3,0];
|
|
const fwd=nrm2([at[0]-eye[0],at[1]-eye[1],at[2]-eye[2]]);
|
|
const right=nrm2(crs(fwd,[0,1,0])), up=crs(right,fwd);
|
|
const dir=nrm2([fwd[0]+right[0]*ndc[0]+up[0]*ndc[1],
|
|
fwd[1]+right[1]*ndc[0]+up[1]*ndc[1],
|
|
fwd[2]+right[2]*ndc[0]+up[2]*ndc[1]]);
|
|
const m = vox.get();
|
|
let p=[eye[0]+N/2, eye[1], eye[2]+N/2], last=null;
|
|
for(let i=0;i<400;i++){
|
|
const cell=[Math.floor(p[0]),Math.floor(p[1]),Math.floor(p[2])];
|
|
if(cell[0]>=0&&cell[0]<N&&cell[1]>=0&&cell[1]<N&&cell[2]>=0&&cell[2]<N && m[cell]!==undefined)
|
|
return {cell, prev:last};
|
|
last = cell;
|
|
p=[p[0]+dir[0]*0.25, p[1]+dir[1]*0.25, p[2]+dir[2]*0.25];
|
|
if(p[1]<-2||p[1]>N+6) break;
|
|
}
|
|
return null;
|
|
}
|
|
cv.addEventListener('contextmenu', e=>e.preventDefault());
|
|
cv.addEventListener('pointerdown', e=>{
|
|
cv.setPointerCapture(e.pointerId);
|
|
if(e.button!==0 || e.shiftKey){ drag=[e.clientX,e.clientY]; return; }
|
|
const r0=cv.getBoundingClientRect(), k=cv.width/r0.width;
|
|
const hit = pick((e.clientX-r0.left)*k, (e.clientY-r0.top)*k);
|
|
if(!hit) return;
|
|
undo.push(structuredClone(vox.get()));
|
|
if(mode==='place'){ const c=hit.prev;
|
|
if(c && c.every(v=>v>=0&&v<N)) vox.set(m=>({...m,[c]:color})); }
|
|
else vox.set(m=>{ const n={...m}; delete n[hit.cell]; return n; });
|
|
upload();
|
|
});
|
|
addEventListener('pointerup', ()=>drag=null);
|
|
cv.addEventListener('pointermove', e=>{ if(!drag) return;
|
|
yaw -= (e.clientX-drag[0])*0.008;
|
|
pitch = Math.max(-0.2, Math.min(1.4, pitch+(e.clientY-drag[1])*0.006));
|
|
drag=[e.clientX,e.clientY]; });
|
|
cv.addEventListener('wheel', e=>{ e.preventDefault(); dist=Math.max(12,Math.min(80,dist+e.deltaY*0.03)); },{passive:false});
|
|
$('#mode').onclick = () => { mode = mode==='place'?'remove':'place';
|
|
$('#mode').textContent = mode==='place'?'Place':'Remove';
|
|
$('#mode').classList.toggle('p', mode==='place'); };
|
|
$('#undo').onclick = () => { const p2=undo.pop(); if(!p2) return toast('Nothing to undo');
|
|
vox.set(p2); upload(); };
|
|
$('#clear').onclick = () => { undo.push(structuredClone(vox.get()));
|
|
vox.set(Object.fromEntries(Object.entries(START).filter(([k])=>k.split(',')[1]==='0'))); upload(); toast('Cleared to the floor'); };
|
|
$('#obj').onclick = () => {
|
|
const m = vox.get(); let out='# exported by an official Hanzo example\n', vi=1;
|
|
Object.keys(m).forEach(k=>{ const [x,y,z]=k.split(',').map(Number);
|
|
const c=[[0,0,0],[1,0,0],[1,1,0],[0,1,0],[0,0,1],[1,0,1],[1,1,1],[0,1,1]];
|
|
c.forEach(v=>out+='v '+(x+v[0])+' '+(y+v[1])+' '+(z+v[2])+'\n');
|
|
[[1,2,3,4],[5,8,7,6],[1,5,6,2],[2,6,7,3],[3,7,8,4],[4,8,5,1]]
|
|
.forEach(f2=>out+='f '+f2.map(n=>n+vi-1).join(' ')+'\n');
|
|
vi+=8; });
|
|
h('a',{download:'voxel.obj',href:URL.createObjectURL(new Blob([out],{type:'text/plain'}))}).click();
|
|
submit('voxel.export',{voxels:Object.keys(m).length}).catch(()=>{});
|
|
toast('Exported '+Object.keys(m).length+' cubes');
|
|
};
|
|
fill($('#pal'), COLORS.map((c,i)=>h('button.sm',{style:{background:c,width:'22px',height:'22px',padding:0,
|
|
border: i===color?'2px solid var(--fg)':'1px solid var(--line2)'},
|
|
onclick:()=>{ color=i; fill($('#pal'),[]); palette(); }})));
|
|
function palette(){ fill($('#pal'), COLORS.map((c,i)=>h('button.sm',{style:{background:c,width:'22px',height:'22px',padding:0,
|
|
border: i===color?'2px solid var(--fg)':'1px solid var(--line2)'}, onclick:()=>{color=i;palette();}}))); }
|
|
palette();
|
|
fill($('#ui'),
|
|
h('div.small.muted','Grid '+N+'³ · instanced rendering, one draw call'),
|
|
h('div.field',{style:{margin:0}},h('label','Zoom'),
|
|
h('input',{type:'range',min:12,max:80,value:dist,oninput:e=>dist=+e.target.value})),
|
|
h('div.field',{style:{margin:0}},h('label','Pitch'),
|
|
h('input',{type:'range',min:-20,max:140,value:pitch*100,oninput:e=>pitch=+e.target.value/100})),
|
|
h('p.small.muted',{style:{margin:0}},'Click a face to add a cube against it; switch to Remove to delete the cube you click.'));
|
|
let fr=0, tl=performance.now();
|
|
function frame(now){
|
|
const dpr=Math.min(devicePixelRatio||1,1.6);
|
|
const w=Math.round(cv.clientWidth*dpr), hh=Math.round(w*10/16);
|
|
if(cv.width!==w){ cv.width=w; cv.height=hh; gl.viewport(0,0,w,hh); }
|
|
gl.clearColor(0.031,0.039,0.067,1); gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
|
|
gl.uniformMatrix4fv(gl.getUniformLocation(prog,'MVP'),false, mat().mvp);
|
|
gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, instances);
|
|
if(++fr>=30){ $('#fps').textContent=Math.round(fr*1000/(now-tl))+' fps · '+instances+' instances'; fr=0; tl=now; }
|
|
requestAnimationFrame(frame);
|
|
}
|
|
upload(); if(gl) requestAnimationFrame(frame); else $('#fps').textContent='WebGL2 unavailable';
|
|
</script>
|