+ A pattern described rather than drawn. The canvas is sized to a
+ common multiple of every layer's period, so the result tiles seamlessly.
+
+
+
+
+
+
+
Export
@@ -1609,6 +1624,64 @@
const openOv=id=>{$(id).hidden=false;};
const closeOv=id=>{$(id).hidden=true;};
$('imgBtn').onclick=()=>openOv('ovImg');
+
+/* The program panel. Everything here runs against the same dslRender the
+ verification suite holds byte-for-byte against the Python oracle, so what is
+ rendered in the panel is what the fixtures cover. */
+const DSL_PRESETS={
+ Stripes:{canvas:{width:32,height:32,scale:1,autoSize:true},
+ layers:[{op:'set',shape:{type:'stripes',axis:'v',period:8,thickness:4}}],post:{}},
+ Diagonal:{canvas:{width:32,height:32,scale:1,autoSize:true},
+ layers:[{op:'set',shape:{type:'diagonal',dx:1,dy:1,period:8,thickness:3}}],post:{}},
+ Checker:{canvas:{width:32,height:32,scale:1,autoSize:true},
+ layers:[{op:'set',shape:{type:'checker',cellW:4,cellH:4}}],post:{}},
+ Plaid:{canvas:{width:32,height:32,scale:1,autoSize:true},
+ layers:[{op:'set',shape:{type:'stripes',axis:'v',period:8,thickness:3}},
+ {op:'xor',shape:{type:'stripes',axis:'h',period:6,thickness:2}}],post:{}},
+ Houndstooth:{canvas:{width:32,height:32,scale:1,autoSize:true},
+ layers:[{op:'set',shape:{type:'checker',cellW:4,cellH:4}},
+ {op:'xor',shape:{type:'diagonal',dx:1,dy:1,period:8,thickness:4}}],post:{}},
+};
+function dslShow(prog){$('dslSrc').value=JSON.stringify(prog,null,1);dslPreviewStat();}
+function dslPreviewStat(){
+ try{
+ const r=dslRender(JSON.parse($('dslSrc').value));
+ const ink=r.bits.reduce((a,b)=>a+b,0);
+ $('dslStat').innerHTML=''+r.w+'×'+r.h+
+ ' · '+Math.round(100*ink/(r.w*r.h))+'% ink · scale '+(1<';
+ }catch(e){
+ $('dslStat').innerHTML=''+e.message+'';}
+}
+(()=>{const el=$('dslPresets');
+ for(const name of Object.keys(DSL_PRESETS)){
+ const b=document.createElement('button');b.className='b';b.textContent=name;
+ b.onclick=()=>dslShow(DSL_PRESETS[name]);el.appendChild(b);}})();
+$('dslBtn').onclick=()=>{
+ if(!$('dslSrc').value.trim()) dslShow(DSL_PRESETS.Diagonal);
+ openOv('ovDsl');dslPreviewStat();};
+$('dslX').onclick=()=>closeOv('ovDsl');
+$('ovDsl').addEventListener('pointerdown',e=>{if(e.target===$('ovDsl'))closeOv('ovDsl');});
+$('dslSrc').oninput=dslPreviewStat;
+$('dslGo').onclick=()=>{
+ let r;
+ try{ r=dslRender(JSON.parse($('dslSrc').value)); }
+ catch(e){ $('dslStat').innerHTML=''+e.message+''; return; }
+ snap();
+ doc.w=r.w;doc.h=r.h;
+ // A program is one bit per pixel, which is the two-colour document exactly:
+ // slot 1 is the primary the format calls bit 0, slot 2 the secondary. Keep an
+ // existing duotone rather than resetting the user's colours.
+ if(doc.palette.length!==3)
+ doc.palette=[{r:0,g:0,b:0,a:0},{r:255,g:255,b:255,a:255},{r:0,g:0,b:0,a:255}];
+ doc.layers=[{name:'Program',visible:true,locked:false,opacity:1}];
+ const idx=new Uint8Array(r.w*r.h);
+ for(let i=0;icloseOv('ovImg');
$('ovImg').addEventListener('pointerdown',e=>{if(e.target===$('ovImg'))closeOv('ovImg');});
$('drop').onclick=()=>$('file').click();
@@ -1819,6 +1892,269 @@
const px=floorMod(wx>>d.scale,d.w),py=floorMod(wy>>d.scale,d.h);
return d.bits[py*d.w+px]===0;
}
+
+/* ═══════ pattern DSL ═══════
+ A pattern described as a program rather than drawn: 15 primitives, each with a
+ declared period, composed with set/union/intersect/xor/subtract and sized so
+ the result tiles seamlessly by construction. Ported from tools/dsl_prototype.py,
+ which tools/dsl_demo.py proves tiles 28/28, and held byte-for-byte against
+ tests/fixtures/dsl.json.
+
+ Four things do NOT survive a literal translation from Python, and every one of
+ them is a silent wrong answer rather than an error:
+
+ * `round()` is banker's rounding — round(2.5) is 2, round(3.5) is 4 —
+ where Math.round always goes up. It decides wave offsets and canvas
+ sizing, so pyRound() below reproduces it.
+ * `%` is a floor modulo in Python and a remainder in JS. Any expression
+ that can go negative (a diagonal's x*dy - y*dx, a wave's y - offset)
+ needs floorMod.
+ * `//` floors; `|0` truncates toward zero. Same thing only for non-negative
+ operands, so Math.floor is used throughout rather than a bit trick.
+ * _hash2 multiplies by constants near 2^31. A plain `*` passes 2^53 and
+ silently drops low bits, so the mixing steps go through Math.imul.
+*/
+const DSL_MIN_W=2,DSL_MAX_W=129,DSL_MIN_H=2,DSL_MAX_H=65,DSL_MAX_LAYERS=4;
+
+/** Python's round(): halves go to the nearest even, not always up. */
+function pyRound(x){
+ const f=Math.floor(x),d=x-f;
+ if(d>0.5) return f+1;
+ if(d<0.5) return f;
+ return f%2===0?f:f+1;
+}
+function gcd(a,b){a=Math.abs(a);b=Math.abs(b);while(b){const t=a%b;a=b;b=t;}return a||1;}
+function lcm(a,b){return Math.abs(a*b)/gcd(a,b);}
+
+/** Deterministic [0,1). Stable across languages, which is why noise fixtures work. */
+function hash2(x,y,seed){
+ let h=(Math.imul(x,374761393)+Math.imul(y,668265263)+Math.imul(seed,2246822519))>>>0;
+ h=Math.imul(h^(h>>>13),1274126177)>>>0;
+ return ((h^(h>>>16))>>>0)/4294967296;
+}
+
+/* Bitmap fonts for the `text` primitive: one integer per row, MSB-leftmost. */
+const F57={0:[14,17,19,21,25,17,14],1:[4,12,4,4,4,4,14],2:[14,17,1,2,4,8,31],3:[31,2,4,2,1,17,14],4:[2,6,10,18,31,2,2],5:[31,16,30,1,1,17,14],6:[6,8,16,30,17,17,14],7:[31,1,2,4,8,8,8],8:[14,17,17,14,17,17,14],9:[14,17,17,15,1,2,12],A:[14,17,17,31,17,17,17],B:[30,17,17,30,17,17,30],C:[14,17,16,16,16,17,14],D:[30,17,17,17,17,17,30],E:[31,16,16,30,16,16,31],F:[31,16,16,30,16,16,16],G:[14,17,16,23,17,17,15],H:[17,17,17,31,17,17,17],I:[14,4,4,4,4,4,14],J:[7,2,2,2,2,18,12],K:[17,18,20,24,20,18,17],L:[16,16,16,16,16,16,31],M:[17,27,21,21,17,17,17],N:[17,25,21,19,17,17,17],O:[14,17,17,17,17,17,14],P:[30,17,17,30,16,16,16],Q:[14,17,17,17,21,18,13],R:[30,17,17,30,20,18,17],S:[15,16,16,14,1,1,30],T:[31,4,4,4,4,4,4],U:[17,17,17,17,17,17,14],V:[17,17,17,17,17,10,4],W:[17,17,17,21,21,27,17],X:[17,17,10,4,10,17,17],Y:[17,17,10,4,4,4,4],Z:[31,1,2,4,8,16,31]};
+const F35={0:[7,5,5,5,7],1:[2,6,2,2,7],2:[6,1,2,4,7],3:[6,1,2,1,6],4:[5,5,7,1,1],5:[7,4,6,1,6],6:[3,4,7,5,7],7:[7,1,2,2,2],8:[7,5,7,5,7],9:[7,5,7,1,6],A:[2,5,7,5,5],B:[6,5,6,5,6],C:[3,4,4,4,3],D:[6,5,5,5,6],E:[7,4,6,4,7],F:[7,4,6,4,4],G:[3,4,5,5,3],H:[5,5,7,5,5],I:[7,2,2,2,7],J:[1,1,1,5,2],K:[5,5,6,5,5],L:[4,4,4,4,7],M:[5,7,7,5,5],N:[5,7,7,7,5],O:[2,5,5,5,2],P:[6,5,6,4,4],Q:[2,5,5,6,3],R:[6,5,6,5,5],S:[3,4,2,1,6],T:[7,2,2,2,2],U:[5,5,5,5,7],V:[5,5,5,5,2],W:[5,5,7,7,5],X:[5,5,2,5,5],Y:[5,5,2,2,2],Z:[7,1,2,4,7]};
+
+/* Each primitive returns {fn,px,py}: fn(x,y)->0|1, genuinely periodic with the
+ declared period. Those periods are what size_canvas uses to guarantee tiling. */
+const DSL_SHAPES={
+ solid:()=>({fn:()=>1,px:1,py:1}),
+ stripes:(p)=>{
+ const period=p.period??8,phase=p.phase??0;
+ const th=Math.min(p.thickness??4,period-1);
+ return p.axis==='h'
+ ?{fn:(x,y)=>floorMod(y+phase,period)