From 630636b3af5864e2d9fc511982b568c9ebaa7ec8 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sun, 5 Jul 2026 13:29:21 +0100 Subject: [PATCH] Wall the city in and paste posters on it A 14 m concrete wall now encloses the city 24 m beyond its outermost blocks. It is in the collision index (not the pick index), blocked on the navigation grid so residents and routes stay inside, and walk/fly positions are clamped within it so nobody leaves by flying over. Posters are pasted on the city side at random spots, sizes and tilts (up to 25 degrees), one per ~35 m, overlapping like a real hoarding. They are painted into one atlas from civic slogans plus facts about the loaded codebase (welcome, tallest tower, largest district, most connected file), seeded by the repository name so each city's wall is stable. The wall shader draws concrete courses and joints with a lit coping, and the minimap outlines the wall. README covers routes, residents, the wall and security alerts. --- README.md | 5 ++++ web/app.js | 52 +++++++++++++++++++++++++++++++------- web/city.js | 44 ++++++++++++++++++++++++++++++-- web/labels.js | 60 ++++++++++++++++++++++++++++++++++++++++++++ web/renderer.js | 27 ++++++++++++++++++-- webtest/nav.test.mjs | 32 +++++++++++++++++++++++ 6 files changed, 207 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 8aa38a1..303c82c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,11 @@ Configuration: - `T` or **Tour** flies over the largest districts. **Copy link** shares the repository, view, camera and selected file as a URL fragment (GitHub repositories only). - Touch: left thumb joystick, right thumb look, pinch to zoom. + - Selecting a file also lays glowing routes along the streets to each file it imports. + - Residents wander the streets (toggle in Settings). A wall with posters encloses the city. + - Signed in with GitHub, files with open code scanning or Dependabot alerts smoke (and burn + for critical/high) and are wrapped in hazard tape. The GitHub App needs read access to + code scanning alerts and Dependabot alerts. Frontend modules (plain ES modules, no build step): `camera.js` (matrices and orbit pose), `city.js` (layout, collision, picking, sign placement, tour and links), `labels.js` (text atlas), diff --git a/web/app.js b/web/app.js index 368b5c3..60c5aa7 100644 --- a/web/app.js +++ b/web/app.js @@ -1,6 +1,6 @@ import { LandscapeRenderer, KIND } from './renderer.js'; -import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, flatQuad, wallSigns, folderBlades, folderLabel, facadeQuad, heading, tourStops, encodePlace, decodePlace, joystick, buildNavGrid, routesFrom, spawnWanderers, stepWanderers, alertsByPath, burns, tapeQuads, MOVE } from './city.js'; -import { LabelAtlas } from './labels.js'; +import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, flatQuad, wallSigns, folderBlades, folderLabel, facadeQuad, heading, tourStops, encodePlace, decodePlace, joystick, buildNavGrid, routesFrom, spawnWanderers, stepWanderers, alertsByPath, burns, tapeQuads, cityWalls, posterQuads, seededRandom, WALL, MOVE } from './city.js'; +import { LabelAtlas, PosterAtlas } from './labels.js'; import { apiFetch, progressEvents } from './api.mjs'; const configuredBackend=window.CODENAVIGATOR_CONFIG?.backendUrl?.trim()||''; @@ -46,7 +46,7 @@ let searchHits=[]; let expandedCoverageLayer=null; let coverageActiveIndex=-1; let cameraAnimation=null; -let cityModel=null,cityEntries=[],cityIndex=null,cityFitted=false,cityDistricts=[],cityFolders=[],navGrid=null,cityRoutes=[],wanderers=[],showResidents=true; +let cityModel=null,cityEntries=[],cityIndex=null,collisionIndex=null,cityWallBoxes=[],cityFitted=false,cityDistricts=[],cityFolders=[],navGrid=null,cityRoutes=[],wanderers=[],showResidents=true; let currentSnapshot=null; let currentName=''; let currentMetric='lines'; @@ -128,7 +128,10 @@ function computeCity(){ instances.push({...building,color:item.color,atlas:[item.x/1000,item.y/680,item.w/1000,item.h/680],kind:KIND.building,id:item.id,lit:Math.min(.85,.06+Math.sqrt(symbols)/9)}); } cityIndex=spatialIndex(cityEntries); - navGrid=buildNavGrid(cityEntries,cityModel.blocks,cityModel); + // The wall keeps walkers, residents and routes inside; it's in the collision index but not the pick index. + cityWallBoxes=cityWalls(cityModel); + collisionIndex=spatialIndex([...cityEntries,...cityWallBoxes]); + navGrid=buildNavGrid([...cityEntries,...cityWallBoxes],cityModel.blocks,cityModel); updateFires(); spawnResidents(); // Each folder's own buildings (not its subfolders'), for street-corner signs. @@ -137,6 +140,8 @@ function computeCity(){ cityFolders=cityModel.blocks.map(block=>({block,buildings:byDirectory.get(block.node.path)||[],label:folderLabel(block.node.path,currentName)})).filter(folder=>folder.buildings.length); const tally=node=>{let files=node.files.length,lines=node.files.reduce((n,f)=>n+(f.lines||0),0);for(const child of node.children.values()){const t=tally(child);files+=t.files;lines+=t.lines;}return{files,lines};}; cityDistricts=cityModel.blocks.filter(block=>block.depth===1).map(block=>({block,...tally(block.node),top:Math.max(10,...[...cityIndex.near(block.x+block.w/2,block.y+block.h/2,Math.hypot(block.w,block.h)/2)].filter(b=>b.x>=block.x&&b.x+b.w<=block.x+block.w&&b.y>=block.y&&b.y+b.h<=block.y+block.h).map(b=>b.height))})); + for(const wall of cityWallBoxes)instances.push({...wall,color:[.55,.5,1],kind:KIND.wall}); + pastePosters(); renderer.setCity(instances,{width:cityModel.width,height:cityModel.height}); buildMinimap();updateCityHud();updateTrails();updateRoutes();updateBeacons();signState.key='';facadeState={entry:null,texture:null};renderer.setFacade(null); } @@ -171,11 +176,11 @@ function setCityView(view,at){ if(fromHeli)Object.assign(c,{ex:eye[0],ey:eye[1],ez:eye[2],lookYaw:Math.atan2(-forward[0],-forward[1]),lookPitch:Math.asin(Math.max(-1,Math.min(1,forward[2])))}); // With routes showing, walking starts at the selected building's door facing down the first route. const route=!at&&view==='walk'&&cityRoutes[0]?.points.length>1?cityRoutes[0].points:null; - const target=at||route?.[0]||[c.x,c.y],spot=collide(target[0],target[1],MOVE.radius*3,cityIndex); + const target=at||route?.[0]||[c.x,c.y],spot=collide(target[0],target[1],MOVE.radius*3,collisionIndex); const facing=route?Math.atan2(-(route[1][0]-route[0][0]),-(route[1][1]-route[0][1])):null; animateCameraTo({ex:spot.x,ey:spot.y,ez:view==='walk'?MOVE.eyeHeight:Math.min(Math.max(60,c.ez*.5),260),lookYaw:facing??(view==='walk'?openHeading(spot.x,spot.y,c.lookYaw):c.lookYaw),lookPitch:view==='walk'?(route?-.18:0):-.3},c,1100); }else if(view==='walk'){ - const spot=collide(c.ex,c.ey,MOVE.radius*3,cityIndex); + const spot=collide(c.ex,c.ey,MOVE.radius*3,collisionIndex); animateCameraTo({ex:spot.x,ey:spot.y,ez:MOVE.eyeHeight,lookPitch:0},c,800); } } @@ -200,7 +205,7 @@ function stepCity(seconds){ right:Math.max(-1,Math.min(1,down('KeyD')+down('ArrowRight')-down('KeyA')-down('ArrowLeft')+touchMove.right)), up:down('Space')-down('KeyC'), run:heldKeys.has('ShiftLeft')||heldKeys.has('ShiftRight'), - },seconds,cityIndex,cityModel); + },seconds,collisionIndex,{width:cityModel.width,height:cityModel.height,edge:WALL.margin-MOVE.radius}); dirty=true; } @@ -359,12 +364,14 @@ function updateCityReadouts(){ const MINIMAP={width:220,height:150,pad:8}; function minimapTransform(){ - const scale=Math.min((MINIMAP.width-MINIMAP.pad*2)/cityModel.width,(MINIMAP.height-MINIMAP.pad*2)/cityModel.height); + // Leave room for the city wall around the edge. + const scale=Math.min((MINIMAP.width-MINIMAP.pad*2)/(cityModel.width+WALL.margin*2),(MINIMAP.height-MINIMAP.pad*2)/(cityModel.height+WALL.margin*2)); return {scale,ox:(MINIMAP.width-cityModel.width*scale)/2,oy:(MINIMAP.height-cityModel.height*scale)/2}; } function buildMinimap(){ const dpr=2,canvas=document.createElement('canvas');canvas.width=MINIMAP.width*dpr;canvas.height=MINIMAP.height*dpr; const ctx=canvas.getContext('2d'),{scale,ox,oy}=minimapTransform();ctx.scale(dpr,dpr); + ctx.strokeStyle='rgba(141,125,255,.55)';ctx.lineWidth=1.5;ctx.strokeRect(ox-WALL.margin*scale,oy-WALL.margin*scale,(cityModel.width+WALL.margin*2)*scale,(cityModel.height+WALL.margin*2)*scale); ctx.fillStyle='rgba(141,125,255,.08)'; for(const block of cityModel.blocks)if(block.depth===1)ctx.fillRect(ox+block.x*scale,oy+block.y*scale,block.w*scale,block.h*scale); for(const item of layoutItems){const b=item.city;if(!b||!fileAlerts.has(item.path))continue;ctx.fillStyle='#ff4d3d';ctx.beginPath();ctx.arc(ox+(b.x+b.w/2)*scale,oy+(b.y+b.h/2)*scale,2.6,0,Math.PI*2);ctx.fill();} @@ -393,7 +400,7 @@ $('#minimap').addEventListener('pointerdown',event=>{ const rect=event.currentTarget.getBoundingClientRect(),{scale,ox,oy}=minimapTransform(); const x=(event.clientX-rect.left-ox)/scale,y=(event.clientY-rect.top-oy)/scale,c=renderer.city; if(c.view==='heli')animateCameraTo({x,y},c,700); - else{const spot=c.view==='walk'?collide(x,y,MOVE.radius*3,cityIndex):{x,y};animateCameraTo({ex:spot.x,ey:spot.y},c,700);} + else{const spot=c.view==='walk'?collide(x,y,MOVE.radius*3,collisionIndex):{x,y};animateCameraTo({ex:spot.x,ey:spot.y},c,700);} }); function fitCity(){ @@ -781,6 +788,33 @@ function updateRoutes(){ renderer.setRoutes(cityRoutes);dirty=true; } +// Posters on the city wall: civic slogans plus facts about this codebase, placed from a seed of +// the repository name so the same city always has the same wall. +function pastePosters(){ + const byComplexity=[...files].sort((a,b)=>(b.complexity||0)-(a.complexity||0))[0]; + const biggest=[...cityDistricts].sort((a,b)=>b.lines-a.lines)[0]; + const connected=[...referenceDegree.entries()].sort((a,b)=>b[1]-a[1])[0]; + const designs=[ + {title:`Welcome to ${currentName}`,body:`${format(files.length)} files · ${format(sceneStats.totalLines)} lines of code`}, + {title:'Refactor mercilessly',body:'Leave every file better than you found it.'}, + {title:'Tests are love letters',body:'to whoever touches this code next.'}, + {title:'YAGNI',body:"You aren't gonna need it. Probably."}, + {title:'Delete dead code',body:'The best line is the one you remove.'}, + {title:'Beware circular imports',body:'Report suspicious dependencies at the nearest minimap.'}, + {title:'Read the docs',body:'Then write the docs you wished you had read.'}, + {title:'Ship small',body:'Big-bang releases are loud for a reason.'}, + {title:'Name things well',body:'There are only two hard problems.'}, + {title:'Keep it boring',body:'Clever code is a 3 a.m. page waiting to happen.'}, + {title:'Fires are not a vibe',body:'Security alerts are everyone’s job.'}, + ...(byComplexity?[{title:'Tallest tower',body:`${byComplexity.name}, complexity ${format(byComplexity.complexity)}`}]:[]), + ...(biggest?[{title:`Visit ${biggest.block.node.name}`,body:`The largest district: ${format(biggest.files)} files`}]:[]), + ...(connected?[{title:'Most connected',body:`${fileById.get(connected[0])?.name||'?'} · ${format(connected[1])} links`}]:[]), + ]; + let seed=0;for(const char of currentName)seed=(seed*31+char.charCodeAt(0))|0; + const atlas=new PosterAtlas(designs); + renderer.setPosters(atlas,posterQuads(cityWallBoxes,designs.length,seededRandom(seed)).map(quad=>({...quad,uv:atlas.uvs[quad.poster]}))); +} + // Residents: roughly one per 2,500 m² of city, capped so large repos stay cheap to simulate. function spawnResidents(){ wanderers=showResidents&&navGrid?spawnWanderers(navGrid,Math.max(40,Math.min(600,Math.round(cityModel.width*cityModel.height/2500))),Math.random,cityModel):[]; diff --git a/web/city.js b/web/city.js index e37cbcb..750bc7a 100644 --- a/web/city.js +++ b/web/city.js @@ -190,8 +190,10 @@ export function stepCamera(camera, input, dt, index, bounds) { camera.ez = MOVE.eyeHeight; } if (camera.ez < 400) ({ x, y } = collide(x, y, MOVE.radius, { near: (px, py, r) => [...index.near(px, py, r)].filter(b => b.height + .5 > camera.ez - MOVE.eyeHeight) })); - camera.ex = Math.max(-150, Math.min(bounds.width + 150, x)); - camera.ey = Math.max(-150, Math.min(bounds.height + 150, y)); + // Never beyond the city wall, even when flying over it. + const edge = bounds.edge ?? 150; + camera.ex = Math.max(-edge, Math.min(bounds.width + edge, x)); + camera.ey = Math.max(-edge, Math.min(bounds.height + edge, y)); return camera; } @@ -600,3 +602,41 @@ export function tapeQuads(b, aspect, direction = 1) { } return out; } + +// ---- The city wall and its posters ---- + +export const WALL = { margin: 24, height: 14, thickness: 2 }; + +// Four boxes enclosing the city `margin` metres beyond its edge; `inward` is the city-facing normal. +export function cityWalls(bounds, { margin, height, thickness } = WALL) { + const x0 = -margin - thickness, y0 = -margin - thickness, x1 = bounds.width + margin, y1 = bounds.height + margin, span = x1 + thickness - x0; + return [ + { x: x0, y: y0, w: span, h: thickness, height, inward: [0, 1] }, + { x: x0, y: y1, w: span, h: thickness, height, inward: [0, -1] }, + { x: x0, y: y0, w: thickness, h: y1 + thickness - y0, height, inward: [1, 0] }, + { x: x1, y: y0, w: thickness, h: y1 + thickness - y0, height, inward: [-1, 0] }, + ]; +} + +// Posters pasted on the city side of the walls at random spots, sizes and tilts (up to ±25°), +// one per ~35 m of wall. Each quad carries `poster`, an index into `designs`. +export function posterQuads(walls, designs, random = Math.random) { + const out = []; + for (const wall of walls) { + const [nx, ny] = wall.inward, length = nx ? wall.h : wall.w; + const along = [ny, -nx]; // left to right for someone facing the wall from inside + const face = [wall.x + wall.w / 2 + nx * wall.w / 2, wall.y + wall.h / 2 + ny * wall.h / 2]; + for (let i = 0, count = Math.max(1, Math.round(length / 35)); i < count; i++) { + const height = 3 + random() * 2.5, width = height * .68, tilt = (random() - .5) * .87; + const offset = (random() - .5) * (length - 12), z = 2.2 + height / 2 + random() * (wall.height - height - 3.5); + const cos = Math.cos(tilt), sin = Math.sin(tilt), lift = .05 + i * .002; // later posters sit on top + out.push({ + c: [face[0] + along[0] * offset + nx * lift, face[1] + along[1] * offset + ny * lift, z], + u: [along[0] * cos * width / 2, along[1] * cos * width / 2, sin * width / 2], + v: [-along[0] * sin * height / 2, -along[1] * sin * height / 2, cos * height / 2], + poster: Math.floor(random() * designs), + }); + } + } + return out; +} diff --git a/web/labels.js b/web/labels.js index 8a9944f..3d2f0d9 100644 --- a/web/labels.js +++ b/web/labels.js @@ -84,3 +84,63 @@ export class LabelAtlas { return { x: d.x0, y: d.y0, image: this.ctx.getImageData(d.x0, d.y0, w, h) }; } } + +// Posters for the city wall, painted into one atlas. Each design is { title, body, theme }. +const POSTER_THEMES = [ + { paper: '#f2e6c9', ink: '#1d1d1f', accent: '#d7263d' }, + { paper: '#1b2a4a', ink: '#f5efe0', accent: '#f5c518' }, + { paper: '#d7263d', ink: '#fff6e8', accent: '#141414' }, + { paper: '#3ddc97', ink: '#0b1f17', accent: '#0b1f17' }, + { paper: '#8d7dff', ink: '#ffffff', accent: '#36d3c2' }, + { paper: '#f5c518', ink: '#141414', accent: '#141414' }, +]; + +export class PosterAtlas { + constructor(designs, width = 256, height = 376, columns = 8) { + const rows = Math.ceil(designs.length / columns); + this.canvas = document.createElement('canvas'); + this.canvas.width = width * columns; this.canvas.height = height * rows; + const ctx = this.canvas.getContext('2d'); + this.uvs = designs.map((design, i) => { + const x = (i % columns) * width, y = Math.floor(i / columns) * height; + ctx.save(); ctx.translate(x, y); paintPoster(ctx, design, width, height, POSTER_THEMES[i % POSTER_THEMES.length], i); ctx.restore(); + return { u: x / this.canvas.width, v: y / this.canvas.height, du: width / this.canvas.width, dv: height / this.canvas.height }; + }); + } +} + +function wrap(ctx, text, maxWidth) { + const lines = []; let line = ''; + for (const word of text.split(/\s+/)) { + const next = line ? `${line} ${word}` : word; + if (line && ctx.measureText(next).width > maxWidth) { lines.push(line); line = word; } else line = next; + } + if (line) lines.push(line); + return lines; +} + +function paintPoster(ctx, { title, body }, w, h, theme, variant) { + const inset = 6; + ctx.fillStyle = theme.paper; ctx.fillRect(inset, inset, w - inset * 2, h - inset * 2); + // Weathering: faint speckles and a darker fold line. + ctx.fillStyle = 'rgba(0,0,0,.06)'; + for (let i = 0; i < 70; i++) ctx.fillRect(inset + ((i * 73 + variant * 31) % (w - inset * 2)), inset + ((i * 137 + variant * 17) % (h - inset * 2)), 2, 2); + ctx.fillRect(inset, h * .52, w - inset * 2, 1.5); + // A bold graphic per variant. + ctx.fillStyle = theme.accent; + const shape = variant % 4; + if (shape === 0) { ctx.beginPath(); ctx.arc(w / 2, 104, 52, 0, Math.PI * 2); ctx.fill(); } + else if (shape === 1) { for (let i = 0; i < 6; i++) { ctx.beginPath(); ctx.moveTo(inset + i * 44, inset); ctx.lineTo(inset + i * 44 + 22, inset); ctx.lineTo(inset + i * 44 - 30, 150); ctx.lineTo(inset + i * 44 - 52, 150); ctx.fill(); } } + else if (shape === 2) { ctx.beginPath(); ctx.moveTo(w / 2, 40); ctx.lineTo(w / 2 + 64, 160); ctx.lineTo(w / 2 - 64, 160); ctx.closePath(); ctx.fill(); ctx.fillStyle = theme.paper; ctx.font = '900 64px Inter, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('!', w / 2, 150); } + else { ctx.fillRect(inset, 40, w - inset * 2, 22); ctx.fillRect(inset, 84, w - inset * 2, 22); ctx.fillRect(inset, 128, w - inset * 2, 22); } + ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillStyle = theme.ink; + ctx.font = '900 34px Inter, system-ui, sans-serif'; + let y = 206; + for (const line of wrap(ctx, title.toUpperCase(), w - 36).slice(0, 3)) { ctx.fillText(line, w / 2, y, w - 30); y += 36; } + ctx.font = '600 17px Inter, system-ui, sans-serif'; + y += 6; + for (const line of wrap(ctx, body, w - 44).slice(0, 4)) { ctx.fillText(line, w / 2, y, w - 30); y += 21; } + ctx.font = '700 10px Inter, system-ui, sans-serif'; ctx.globalAlpha = .7; + ctx.fillText('CODENAVIGATOR CIVIC AUTHORITY', w / 2, h - 18); + ctx.globalAlpha = 1; +} diff --git a/web/renderer.js b/web/renderer.js index d39c105..c9db5cb 100644 --- a/web/renderer.js +++ b/web/renderer.js @@ -16,7 +16,7 @@ const VERTICES = new Float32Array([ ]); // Instance kinds in the box program. -export const KIND = { building: 0, block: 1, ground: 2, beacon: 3 }; +export const KIND = { building: 0, block: 1, ground: 2, beacon: 3, wall: 4 }; const FLOATS_PER_INSTANCE = 16; // Landscape heights are exaggerated relative to the 1000x680 map so small repositories still read as 3D. @@ -107,6 +107,18 @@ void main(){ return; } if(vKind.x == 2){ outColor = vec4(fog(vec3(.03, .036, .052)), uAlpha); return; } // asphalt + if(vKind.x == 4){ + // City wall: concrete courses and panel joints in metres, lit coping along the top. + vec2 faceUv = vFace == 0 || vFace == 5 ? vUnit.xy : vFace < 3 ? vUnit.xz : vUnit.yz; + float along = (vFace < 3 ? vUnit.x * vSize.x : vUnit.y * vSize.y), up = vUnit.z * vSize.z; + float joints = max(step(.97, fract(along / 6.0)), step(.95, fract(up / 2.8))); + vec3 concrete = vec3(.12, .13, .16) * (.65 + .35 * vUnit.z) * (1.0 - joints * .35); + if(vFace == 0) concrete = vec3(.2, .21, .26); + float edgeWall = min(min(faceUv.x, 1.0 - faceUv.x), min(faceUv.y, 1.0 - faceUv.y)); + float rim = 1.0 - smoothstep(0.0, fwidth(edgeWall) * 1.5 + .002, edgeWall); + outColor = vec4(fog(mix(concrete, vec3(.55, .5, 1.0) * .8, rim * step(.9, vUnit.z + (vFace == 0 ? 1.0 : 0.0)))), uAlpha); + return; + } #endif if(uMode < .5 && vFace > 0) discard; vec2 face = vFace == 0 || vFace == 5 ? vUnit.xy : vFace < 3 ? vUnit.xz : vUnit.yz; @@ -418,7 +430,7 @@ export class LandscapeRenderer { this.landscape = this.boxLayer(); this.cityLayer = this.boxLayer(); this.signProgram = program(gl, SIGN_VS, SIGN_FS); this.signUniforms = uniformsOf(gl, this.signProgram); this.corners = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this.corners); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW); - this.signLayer = this.quadLayer(); this.facadeLayer = this.quadLayer(); + this.signLayer = this.quadLayer(); this.facadeLayer = this.quadLayer(); this.posterLayer = this.quadLayer(); this.posterTexture = this.texture(); this.atlasTexture = this.texture(); this.facadeTexture = this.texture(); this.atlasGeneration = -1; this.trailProgram = program(gl, TRAIL_VS, TRAIL_FS); this.trailUniforms = uniformsOf(gl, this.trailProgram); this.trailLayer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 }; @@ -487,6 +499,16 @@ export class LandscapeRenderer { this.signLayer.count = quads.length; } + // Posters on the city wall: a PosterAtlas and quads with uv rects; uploaded once per city. + setPosters(atlas, quads) { + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.posterTexture); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlas.canvas); gl.generateMipmap(gl.TEXTURE_2D); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); + gl.bindBuffer(gl.ARRAY_BUFFER, this.posterLayer.buffer); gl.bufferData(gl.ARRAY_BUFFER, LandscapeRenderer.packQuads(quads), gl.STATIC_DRAW); + this.posterLayer.count = quads.length; + } + // Source code on one wall: a canvas and its quad, or null to hide. setFacade(canvas, quad) { const gl = this.gl; @@ -763,6 +785,7 @@ export class LandscapeRenderer { this.syncAtlas(); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.depthMask(false); gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(-2, -8); + this.drawQuads(this.posterLayer, this.posterTexture); this.drawQuads(this.facadeLayer, this.facadeTexture); this.drawQuads(this.signLayer, this.atlasTexture); gl.disable(gl.POLYGON_OFFSET_FILL); gl.depthMask(true); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); diff --git a/webtest/nav.test.mjs b/webtest/nav.test.mjs index 4d0cf2e..4978cc8 100644 --- a/webtest/nav.test.mjs +++ b/webtest/nav.test.mjs @@ -80,3 +80,35 @@ test('a seeded crowd is reproducible', () => { const run = () => { const r = seededRandom(3), c = spawnWanderers(grid, 10, r); for (let i = 0; i < 100; i++) stepWanderers(c, grid, .05, r); return c.map(w => [w.x.toFixed(3), w.y.toFixed(3)]); }; assert.deepEqual(run(), run()); }); + +import { cityWalls, posterQuads, WALL, stepCamera, readableFrom, spatialIndex } from '../web/city.js'; + +test('the wall encloses the city and nobody gets past it', () => { + const bounds = { width: 200, height: 120 }, walls = cityWalls(bounds); + const inside = (x, y) => walls.every(w => !(x > w.x && x < w.x + w.w && y > w.y && y < w.y + w.h)); + assert.ok(inside(100, 60) && inside(-WALL.margin + 1, -WALL.margin + 1) && !inside(-WALL.margin - 1, 60)); + const index = spatialIndex(walls), edge = WALL.margin - .5; + const walker = { view: 'walk', ex: 100, ey: 60, ez: 1.7, lookYaw: Math.PI / 2, lookPitch: 0 }; // heading west + stepCamera(walker, { forward: 1, right: 0, up: 0 }, 60, index, { ...bounds, edge }); + assert.ok(walker.ex >= -WALL.margin, `walker stopped at the wall: ${walker.ex}`); + const flyer = { view: 'fly', ex: 100, ey: 60, ez: 500, lookYaw: Math.PI / 2, lookPitch: 0 }; + stepCamera(flyer, { forward: 1, right: 0, up: 0 }, 60, index, { ...bounds, edge }); + assert.ok(flyer.ex >= -edge - 1e-9, `flyer stayed inside: ${flyer.ex}`); + // Routes and residents can't reach the outside either. + const grid = buildNavGrid(walls, [], bounds); + assert.ok(walkable(grid, 100, 60) && !walkable(grid, -WALL.margin - 1, 60)); +}); + +test('posters are pasted on the city side of the walls, tilted and readable from inside', () => { + const bounds = { width: 400, height: 300 }, walls = cityWalls(bounds), random = seededRandom(11); + const posters = posterQuads(walls, 12, random); + assert.ok(posters.length >= 36, `about one per 35 m (${posters.length})`); + for (const p of posters) { + assert.ok(readableFrom(p, [200, 150, 5]), 'faces the city'); + assert.ok(p.c[0] > -WALL.margin - .01 && p.c[0] < bounds.width + WALL.margin + .01 && p.c[1] > -WALL.margin - .01 && p.c[1] < bounds.height + WALL.margin + .01, `on the inner face: ${p.c}`); + const tilt = Math.atan2(p.u[2], Math.hypot(p.u[0], p.u[1])); + assert.ok(Math.abs(tilt) <= .44 && p.c[2] - Math.hypot(...p.v) >= 1.5 && p.c[2] + Math.hypot(...p.v) <= WALL.height + .5); + assert.ok(Number.isInteger(p.poster) && p.poster >= 0 && p.poster < 12); + } + assert.ok(new Set(posters.map(p => p.u[2].toFixed(2))).size > 10, 'arbitrary angles'); +});