Skip to content

Instantly share code, notes, and snippets.

@hugoferreira
Created August 20, 2025 23:00
Show Gist options
  • Select an option

  • Save hugoferreira/97d96cd125d749418bb44b381d8e8370 to your computer and use it in GitHub Desktop.

Select an option

Save hugoferreira/97d96cd125d749418bb44b381d8e8370 to your computer and use it in GitHub Desktop.
Quantum Optical Lab
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quantum Optical Lab</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.0/p5.min.js"></script>
<style>
body { margin: 0; padding: 0; overflow: hidden; background: #000; }
canvas { display: block; }
</style>
</head>
<body>
<script>
// --------------------------------------
// Utilities
// --------------------------------------
// Add reflect method to p5.Vector (not built-in)
p5.Vector.prototype.reflect = function(normal) { return this.copy().sub(p5.Vector.mult(normal, 2 * this.dot(normal))); };
// Bitmask color model (fixes composite filter math)
// R=1, G=2, B=4; combinations via bitwise OR
const COLOR_MASKS = { black:0, red:1, green:2, blue:4, yellow:3, cyan:6, magenta:5, white:7 };
const MASK_TO_NAME = { 0:"black", 1:"red", 2:"green", 3:"yellow", 4:"blue", 5:"magenta", 6:"cyan", 7:"white" };
const MASK_TO_RGB = {
0: [0,0,0],
1: [255,100,100], // red
2: [100,255,100], // green
3: [255,255,100], // yellow (R|G)
4: [100,100,255], // blue
5: [255,100,255], // magenta (R|B)
6: [100,255,255], // cyan (G|B)
7: [255,255,255] // white
};
function nameToMask(name){ return COLOR_MASKS[name] ?? 7; }
function maskToName(mask){ return MASK_TO_NAME[mask & 7]; }
// --------------------------------------
// Colors (UI)
// --------------------------------------
let COLORS = {};
function setupColors(){
COLORS = {
bg: color(25, 40, 70),
gridFine: color(60, 90, 140, 80),
gridMajor: color(80, 120, 180, 120),
beamStd: color(160, 160, 160, 180),
beamHot: color(184, 115, 51, 180),
plate: color(100, 100, 100, 200),
hole: color(25, 40, 70),
mirrorBody: color(200, 220, 240, 120),
mirrorSurface: color(255, 255, 255, 180),
mirrorSelected: color(255, 255, 100, 150),
prismBody: color(180, 200, 255, 100),
prismRefraction: color(220, 230, 255, 60),
blackHoleCore: color(0, 0, 0, 200),
blackHoleOuter: color(50, 0, 100, 100),
whiteHoleCore: color(255, 255, 255, 200),
whiteHoleOuter: color(255, 250, 200, 100)
};
}
// --------------------------------------
// Filter definitions (with bitmasks)
// --------------------------------------
const FILTER_TYPES = {
red: { passes:["red"], col:[255,0,0,100], label:"R", passMask: COLOR_MASKS.red },
green: { passes:["green"], col:[0,255,0,100], label:"G", passMask: COLOR_MASKS.green },
blue: { passes:["blue"], col:[0,0,255,100], label:"B", passMask: COLOR_MASKS.blue },
yellow: { passes:["red","green"], col:[255,255,0,100], label:"Y", passMask: (COLOR_MASKS.red|COLOR_MASKS.green) },
cyan: { passes:["green","blue"], col:[0,255,255,100], label:"C", passMask: (COLOR_MASKS.green|COLOR_MASKS.blue) },
magenta: { passes:["red","blue"], col:[255,0,255,100], label:"M", passMask: (COLOR_MASKS.red|COLOR_MASKS.blue) }
};
// --------------------------------------
// Elements
// --------------------------------------
class Mirror {
constructor(start, end, rotSpeed=0){
this.type='mirror';
this.baseStart=start; this.baseEnd=end; this.rotSpeed=rotSpeed; this.angle=0;
this.isRotating=rotSpeed!==0; this.isSelected=false;
this.baseCenter=p5.Vector.lerp(start,end,0.5); this.halfLen=p5.Vector.dist(start,end)/2;
this.updateGeometry();
}
updateGeometry(){
if(this.isRotating){
let dir=p5.Vector.fromAngle(this.angle,this.halfLen);
this.start=p5.Vector.sub(this.baseCenter,dir); this.end=p5.Vector.add(this.baseCenter,dir);
} else { this.start=this.baseStart.copy(); this.end=this.baseEnd.copy(); }
this.center=p5.Vector.lerp(this.start,this.end,0.5); this.len=p5.Vector.dist(this.start,this.end);
let d=p5.Vector.sub(this.end,this.start); this.normal=createVector(-d.y,d.x).normalize();
}
update(){ if(this.isRotating && !this.isSelected){ this.angle+=this.rotSpeed; this.updateGeometry(); } }
moveTo(pos){ let off=p5.Vector.sub(pos,this.center); this.baseCenter.add(off); if(!this.isRotating){ this.baseStart.add(off); this.baseEnd.add(off);} this.updateGeometry(); }
contains(p){ let d=p5.Vector.sub(this.end,this.start); let f=p5.Vector.sub(this.start,p); let a=d.dot(d); let b=2*f.dot(d); let c=f.dot(f)-25; let disc=b*b-4*a*c; if(disc<0) return false; disc=sqrt(disc); let t1=(-b-disc)/(2*a); let t2=(-b+disc)/(2*a); return (t1>=0&&t1<=1)||(t2>=0&&t2<=1)||(t1<0&&t2>1); }
intersectRay(origin,dir,maxDist){ let d=p5.Vector.sub(this.end,this.start); let det=dir.x*d.y - dir.y*d.x; if(abs(det)<1e-4) return null; let toStart=p5.Vector.sub(this.start,origin); let t=(toStart.x*d.y - toStart.y*d.x)/det; let s=(toStart.x*dir.y - toStart.y*dir.x)/det; if(t>0.001 && t<=maxDist && s>=0 && s<=1){ let pt=p5.Vector.add(origin,p5.Vector.mult(dir,t)); let n=this.normal.copy(); if(n.dot(dir)>0) n.mult(-1); return {point:pt, distance:t, normal:n, element:this}; } return null; }
render(){ push(); translate(this.center.x,this.center.y); rotate(atan2(this.end.y-this.start.y,this.end.x-this.start.x));
fill(this.isSelected?COLORS.mirrorSelected: this.isRotating?color(150,255,150,120):COLORS.mirrorBody);
stroke(this.isSelected?color(255,255,0):color(150,170,190)); strokeWeight(this.isSelected?2:1); rectMode(CENTER); rect(0,0,this.len,6);
stroke(this.isSelected?color(255,255,0):COLORS.mirrorSurface); strokeWeight(this.isSelected?3:2); line(-this.len/2,0,this.len/2,0);
if(this.isRotating && !this.isSelected){ noFill(); stroke(100,255,100,100); strokeWeight(1); arc(0,0,20,20,0,abs(this.rotSpeed)*100);} pop(); }
}
class Prism {
constructor(center,size=30,angle=0){ this.type='prism'; this.center=center; this.size=size; this.angle=angle; this.isSelected=false; this.dispersion={1:-0.15, 2:0.0, 4:0.15}; this.updateGeometry(); }
updateGeometry(){ this.vertices=[]; this.edges=[]; this.normals=[]; for(let i=0;i<3;i++){ let a=this.angle+TWO_PI*i/3-PI/2; this.vertices.push(p5.Vector.add(this.center,p5.Vector.fromAngle(a,this.size))); } for(let i=0;i<3;i++){ let j=(i+1)%3; this.edges.push({start:this.vertices[i],end:this.vertices[j]}); let d=p5.Vector.sub(this.vertices[j],this.vertices[i]); this.normals.push(createVector(-d.y,d.x).normalize()); } }
update(){}
moveTo(pos){ this.center=pos; this.updateGeometry(); }
rotate(da){ this.angle+=da; this.updateGeometry(); }
contains(p){ const sign=(p,a,b)=> (p.x-b.x)*(a.y-b.y) - (a.x-b.x)*(p.y-b.y); let d1=sign(p,this.vertices[0],this.vertices[1]); let d2=sign(p,this.vertices[1],this.vertices[2]); let d3=sign(p,this.vertices[2],this.vertices[0]); let hasNeg=(d1<0)||(d2<0)||(d3<0); let hasPos=(d1>0)||(d2>0)||(d3>0); return !(hasNeg&&hasPos); }
intersectRay(origin,dir,maxDist){ let closest=null; for(let i=0;i<3;i++){ let e=this.edges[i]; let d=p5.Vector.sub(e.end,e.start); let det=dir.x*d.y - dir.y*d.x; if(abs(det)<1e-4) continue; let toStart=p5.Vector.sub(e.start,origin); let t=(toStart.x*d.y - toStart.y*d.x)/det; let s=(toStart.x*dir.y - toStart.y*dir.x)/det; if(t>0.001 && t<=maxDist && s>=0 && s<=1){ if(!closest || t<closest.distance){ let pt=p5.Vector.add(origin,p5.Vector.mult(dir,t)); let n=this.normals[i].copy(); if(n.dot(dir)>0) n.mult(-1); closest={point:pt,distance:t,normal:n,element:this}; } } } return closest; }
refract(dir, colorMask){
// Split any multi-component color into separate primary beams (like a dispersive prism)
const components=[1,2,4].filter(bit=> (colorMask & bit));
return components.map(bit=> ({
direction: p5.Vector.fromAngle(dir.heading() + (this.dispersion[bit]||0)),
colorMask: bit
}));
}
render(){ fill(this.isSelected?COLORS.mirrorSelected:COLORS.prismBody); stroke(this.isSelected?color(255,255,0):color(140,160,200)); strokeWeight(this.isSelected?2:1);
beginShape(); for(let v of this.vertices) vertex(v.x,v.y); endShape(CLOSE);
if(!this.isSelected){ noStroke(); fill(COLORS.prismRefraction); beginShape(); for(let v of this.vertices){ let vv=p5.Vector.lerp(v,this.center,0.2); vertex(vv.x,vv.y);} endShape(CLOSE);} }
}
class Filter {
constructor(center,type='red',size=20,angle=0){ this.type='filter'; this.center=center; this.filterType=type; this.size=size; this.angle=angle; this.isSelected=false; this.def=FILTER_TYPES[type]; this.updateGeometry(); }
updateGeometry(){ this.vertices=[]; let corners=[ createVector(-this.size,-this.size/3), createVector(this.size,-this.size/3), createVector(this.size,this.size/3), createVector(-this.size,this.size/3) ]; for(let c of corners){ c.rotate(this.angle); this.vertices.push(p5.Vector.add(this.center,c)); } }
update(){}
moveTo(pos){ this.center=pos; this.updateGeometry(); }
rotate(da){ this.angle+=da; this.updateGeometry(); }
contains(p){ let local=p5.Vector.sub(p,this.center); local.rotate(-this.angle); return abs(local.x)<=this.size+5 && abs(local.y)<=this.size/3+5; }
intersectRay(origin,dir,maxDist){ let closest=null; for(let i=0;i<4;i++){ let j=(i+1)%4; let d=p5.Vector.sub(this.vertices[j],this.vertices[i]); let det=dir.x*d.y - dir.y*d.x; if(abs(det)<1e-4) continue; let toStart=p5.Vector.sub(this.vertices[i],origin); let t=(toStart.x*d.y - toStart.y*d.x)/det; let s=(toStart.x*dir.y - toStart.y*dir.x)/det; if(t>0.001 && t<=maxDist && s>=0 && s<=1){ if(!closest || t<closest.distance){ let pt=p5.Vector.add(origin,p5.Vector.mult(dir,t)); closest={point:pt,distance:t,element:this}; } } } return closest; }
filterLight(colorMask){
const passMask = this.def.passMask; // which components the filter passes
const outMask = colorMask & passMask; // intersection = what survives
if(outMask===0) return [{ colorMask: colorMask, continues:false }]; // fully blocked
return [{ colorMask: outMask, continues:true }]; // possibly reduced (composite->primary)
}
render(){ push(); translate(this.center.x,this.center.y); rotate(this.angle);
const [r,g,b,a] = this.def.col; fill(r,g,b, this.isSelected?150:a); stroke(this.isSelected?255:200); strokeWeight(this.isSelected?2:1); rectMode(CENTER); rect(0,0,this.size*2,this.size*2/3);
fill(255); noStroke(); textAlign(CENTER,CENTER); textSize(14); textStyle(BOLD); text(this.def.label,0,0); pop(); }
}
class QuantumHole {
constructor(center,isBlack=true,linked=null){ this.type='quantumHole'; this.center=center; this.isBlack=isBlack; this.linked=linked; this.radius=20; this.isSelected=false; this.rotAngle=0; this.particleAngle=0; }
setLinked(hole){ this.linked=hole; }
update(){ this.rotAngle += this.isBlack?0.02:-0.02; this.particleAngle+=0.05; }
moveTo(pos){ this.center=pos; }
contains(p){ return p5.Vector.dist(this.center,p) <= this.radius+5; }
intersectRay(origin,dir,maxDist){ let toCenter=p5.Vector.sub(this.center,origin); let projLen=toCenter.dot(dir); if(projLen<0||projLen>maxDist) return null; let projPt=p5.Vector.add(origin,p5.Vector.mult(dir,projLen)); let distToCenter=p5.Vector.dist(projPt,this.center); if(distToCenter>this.radius) return null; let halfChord=sqrt(this.radius*this.radius - distToCenter*distToCenter); let t=projLen - halfChord; if(t<0.001||t>maxDist) return null; return { point:p5.Vector.add(origin,p5.Vector.mult(dir,t)), distance:t, element:this }; }
teleport(dir,colorMask){ if(!this.linked) return null; return { position:p5.Vector.add(this.linked.center,p5.Vector.mult(dir,this.linked.radius+2)), direction:dir, colorMask }; }
render(){ push(); translate(this.center.x,this.center.y);
if(this.isBlack){ for(let i=3;i>0;i--){ fill(50,0,100,this.isSelected?150:100*(4-i)/3); noStroke(); circle(0,0,this.radius*2*(1+i*0.3)); }
push(); rotate(this.rotAngle); noFill(); strokeWeight(2); for(let i=0;i<3;i++){ stroke(150,100,255,100-i*20); arc(0,0,this.radius*3,this.radius*1.5,i*PI/3, i*PI/3+PI/2);} pop();
fill(COLORS.blackHoleCore); noStroke(); circle(0,0,this.radius*2);
} else { push(); rotate(this.rotAngle); noFill(); strokeWeight(2); for(let i=0;i<4;i++){ stroke(200,220,255,100-i*20); arc(0,0,this.radius*3,this.radius*1.5,i*PI/4, i*PI/4+PI/3);} pop(); for(let i=3;i>0;i--){ fill(255,250,200,this.isSelected?150:100*(4-i)/4); noStroke(); circle(0,0,this.radius*2*(1+i*0.4)); }
fill(COLORS.whiteHoleCore); noStroke(); circle(0,0,this.radius*2); noFill(); stroke(255,255,255,100); strokeWeight(1); for(let i=0;i<6;i++){ let a=this.particleAngle+i*PI/3; line(cos(a)*this.radius, sin(a)*this.radius, cos(a)*(this.radius+10), sin(a)*(this.radius+10)); }
}
if(this.isSelected){ noFill(); stroke(255,255,0); strokeWeight(2); circle(0,0,this.radius*2.5); } pop(); }
}
// --------------------------------------
// Laser system
// --------------------------------------
class LaserSystem {
trace(origin, elements){
let beams=[{ pos:origin.copy(), dir:origin.copy().normalize(), colorMask: COLOR_MASKS.white, segments:[], teleports:0 }];
let allSegments=[];
while(beams.length>0){
let beam=beams.shift();
for(let bounce=0; bounce<30; bounce++){
let maxDist=this.getScreenExit(beam.pos,beam.dir);
let closest=null;
for(let el of elements){ let hit=el.intersectRay(beam.pos,beam.dir,maxDist); if(hit && hit.distance>0.1 && (!closest || hit.distance<closest.distance)){ closest=hit; } }
if(closest){
beam.segments.push({ start:beam.pos.copy(), end:closest.point.copy(), colorMask: beam.colorMask });
if(closest.element.type==='quantumHole'){
if(beam.teleports<5 && closest.element.isBlack){ let tp=closest.element.teleport(beam.dir,beam.colorMask); if(tp){ beam.pos=tp.position; beam.dir=tp.direction; beam.teleports++; } else break; } else break;
} else if(closest.element.type==='prism' && beams.length<10){
const refracted=closest.element.refract(beam.dir, beam.colorMask);
for(let r of refracted){ beams.push({ pos:p5.Vector.add(closest.point,p5.Vector.mult(r.direction,2)), dir:r.direction, colorMask:r.colorMask, segments:[], teleports:beam.teleports }); }
break;
} else if(closest.element.type==='filter'){
const filtered=closest.element.filterLight(beam.colorMask)[0];
if(filtered.continues){ beam.pos=p5.Vector.add(closest.point,p5.Vector.mult(beam.dir,1)); beam.colorMask=filtered.colorMask; }
else break;
} else { // mirror
beam.pos=p5.Vector.add(closest.point,p5.Vector.mult(closest.normal,1));
beam.dir=beam.dir.reflect(closest.normal).normalize();
}
} else {
let endPt=p5.Vector.add(beam.pos,p5.Vector.mult(beam.dir,maxDist));
if(maxDist>0.1){ beam.segments.push({ start:beam.pos.copy(), end:endPt, colorMask: beam.colorMask }); }
break;
}
}
allSegments.push(...beam.segments);
}
return allSegments;
}
getScreenExit(pos,dir){ let bounds=225; let t=[]; if(abs(dir.x)>1e-4){ t.push((bounds-pos.x)/dir.x); t.push((-bounds-pos.x)/dir.x);} if(abs(dir.y)>1e-4){ t.push((bounds-pos.y)/dir.y); t.push((-bounds-pos.y)/dir.y);} let valid=t.filter(v=>v>0.01); return valid.length>0? Math.min(...valid):1000; }
render(segments){ blendMode(ADD); for(let seg of segments){ const rgb=MASK_TO_RGB[seg.colorMask]; for(let i=0;i<4;i++){ let w=[12,6,3,1][i]; let a=[40,80,160,200][i]; stroke(rgb[0],rgb[1],rgb[2],a); strokeWeight(w); line(seg.start.x,seg.start.y,seg.end.x,seg.end.y); } } blendMode(BLEND); }
}
// --------------------------------------
// Octagon source
// --------------------------------------
class Octagon {
constructor(radius){ this.radius=radius; this.rotation=0; this.vertices=[]; for(let i=0;i<8;i++){ this.vertices.push(p5.Vector.fromAngle(TWO_PI*i/8, radius)); } }
update(){ this.rotation += TWO_PI/(8*120); }
getHotVertex(){ return this.vertices[1].copy().rotate(this.rotation); }
render(){ push(); rotate(this.rotation);
// Plate
fill(COLORS.plate); stroke(70); strokeWeight(1.5); circle(0,0,this.radius*1.7);
// Center bolt
fill(80); stroke(50); strokeWeight(1); circle(0,0,12); stroke(40); line(-5,0,5,0);
// Beams around
for(let i=0;i<8;i++){
let isHot = i===0 || i===1; let v1=this.vertices[i]; let v2=this.vertices[(i+1)%8];
push(); translate((v1.x+v2.x)/2,(v1.y+v2.y)/2); rotate(atan2(v2.y-v1.y,v2.x-v1.x));
fill(isHot?COLORS.beamHot:COLORS.beamStd); stroke(isHot?120:80); strokeWeight(1); rectMode(CENTER); rect(0,0,p5.Vector.dist(v1,v2)+7,14,7);
fill(COLORS.hole); stroke(200); strokeWeight(0.5); for(let j=-1;j<=1;j++){ circle(j*p5.Vector.dist(v1,v2)/3,0,6); } pop();
}
pop(); }
}
// --------------------------------------
// Main
// --------------------------------------
let octagon, laser, elements, selected, dragOffset, isRotating;
function setup(){
createCanvas(900,900); setupColors(); octagon=new Octagon(40); laser=new LaserSystem();
// Quantum hole pairs
let bh1=new QuantumHole(createVector(-120,-80),true); let wh1=new QuantumHole(createVector(120,80),false); bh1.setLinked(wh1); wh1.setLinked(bh1);
let bh2=new QuantumHole(createVector(80,-120),true); let wh2=new QuantumHole(createVector(-80,120),false); bh2.setLinked(wh2); wh2.setLinked(bh2);
elements=[
bh1, wh1, bh2, wh2,
new Prism(createVector(50,-50),25,PI/6),
new Filter(createVector(-60,-140),'red'),
new Filter(createVector(0,-140),'green'),
new Filter(createVector(60,-140),'blue'),
new Filter(createVector(-100,50),'yellow',20,PI/4),
new Filter(createVector(140,50),'cyan',20,-PI/4),
new Filter(createVector(0,180),'magenta',20,PI/2),
new Mirror(createVector(100,-60),createVector(140,-40)),
new Mirror(createVector(-140,-40),createVector(-100,-60)),
new Mirror(createVector(100,20),createVector(120,-20)),
new Mirror(createVector(-120,-20),createVector(-100,20)),
new Mirror(createVector(-20,140),createVector(20,140)),
new Mirror(createVector(180,0),createVector(210,0),0.02),
new Mirror(createVector(-210,0),createVector(-180,0),0.015)
];
}
function draw(){
background(COLORS.bg);
// Grid
stroke(COLORS.gridFine); strokeWeight(0.5);
for(let i=0;i<=width;i+=20){ line(i,0,i,height); line(0,i,width,i); }
stroke(COLORS.gridMajor); strokeWeight(1);
for(let i=0;i<=width;i+=100){ line(i,0,i,height); line(0,i,width,i); }
push(); translate(width/2,height/2); scale(2);
octagon.update(); for(let el of elements) el.update(); for(let el of elements) el.render();
let segments=laser.trace(octagon.getHotVertex(), elements); laser.render(segments); octagon.render();
pop();
// Instructions
fill(255,200); noStroke(); textAlign(LEFT); textSize(12);
text('Drag elements • Right-click prisms/filters to rotate', 10, 20);
text('Black holes teleport to white holes • Filters: RGB (primary) YCM (secondary)', 10, 38);
}
function mousePressed(){
let world=createVector((mouseX-width/2)/2,(mouseY-height/2)/2);
if(mouseButton===RIGHT){
for(let i=elements.length-1;i>=0;i--){ if(elements[i].contains && elements[i].contains(world)){ if(elements[i].type==='prism' || elements[i].type==='filter'){ selected=elements[i]; isRotating=true; dragOffset=p5.Vector.sub(world,selected.center); return; } } }
} else {
for(let i=elements.length-1;i>=0;i--){ if(elements[i].contains && elements[i].contains(world)){ selected=elements[i]; selected.isSelected=true; isRotating=false; dragOffset=p5.Vector.sub(world,selected.center); return; } }
if(selected){ selected.isSelected=false; selected=null; }
}
}
function mouseDragged(){ if(!selected) return; let world=createVector((mouseX-width/2)/2,(mouseY-height/2)/2); if(isRotating && (selected.type==='prism' || selected.type==='filter')){ let a1=dragOffset.heading(); let a2=p5.Vector.sub(world,selected.center).heading(); selected.rotate(a2-a1); dragOffset=p5.Vector.sub(world,selected.center); } else if(!isRotating){ selected.moveTo(p5.Vector.sub(world,dragOffset)); } }
function mouseReleased(){ if(selected){ selected.isSelected=false; isRotating=false; } }
document.addEventListener('contextmenu', e=>e.preventDefault());
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment