From 4f80ca4744cf0bc662db81ca8f8f8b28b0d56aca Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sat, 30 May 2026 14:56:36 +0100 Subject: [PATCH] Render city text in WebGL: skyline, roof, wall and street signs City labels were a 2D overlay that showed through buildings at street level. labels.js rasterises each label once into a shelf-packed atlas (partial texSubImage uploads, mipmapped for grazing angles), and a new instanced quad pass draws them depth-tested but not depth-writing. District names float above each district's tallest tower facing the camera, file names sit on roofs near the focus, walls facing a walker carry name signs repeated every 24 m, and street names are painted on nearby kerbs. Walking up to the building in the crosshair shows its source on the facing wall. Signs rebuild only when the focus, heading or selection changes; an overflowing atlas is cleared and refilled with the nearest labels first. Placement helpers are unit tested. --- web/app.js | 87 ++++++++++++++++++++++++++----- web/city.js | 53 +++++++++++++++++++ web/labels.js | 77 +++++++++++++++++++++++++++ web/renderer.js | 113 ++++++++++++++++++++++++++++++++++++++++ webtest/labels.test.mjs | 42 +++++++++++++++ 5 files changed, 360 insertions(+), 12 deletions(-) create mode 100644 web/labels.js create mode 100644 webtest/labels.test.mjs diff --git a/web/app.js b/web/app.js index f089320..0a86591 100644 --- a/web/app.js +++ b/web/app.js @@ -1,5 +1,6 @@ import { LandscapeRenderer, KIND } from './renderer.js'; -import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, MOVE } from './city.js'; +import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, flatQuad, wallSigns, facadeQuad, heading, MOVE } from './city.js'; +import { LabelAtlas } from './labels.js'; import { apiFetch, progressEvents } from './api.mjs'; const configuredBackend=window.CODENAVIGATOR_CONFIG?.backendUrl?.trim()||''; @@ -45,7 +46,7 @@ let searchHits=[]; let expandedCoverageLayer=null; let coverageActiveIndex=-1; let cameraAnimation=null; -let cityModel=null,cityEntries=[],cityIndex=null,cityFitted=false; +let cityModel=null,cityEntries=[],cityIndex=null,cityFitted=false,cityDistricts=[]; let currentSnapshot=null; let currentName=''; let currentMetric='lines'; @@ -125,8 +126,9 @@ 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}); } cityIndex=spatialIndex(cityEntries); + cityDistricts=cityModel.blocks.filter(block=>block.depth===1).map(block=>({block,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))})); renderer.setCity(instances,{width:cityModel.width,height:cityModel.height}); - buildMinimap();updateCityHud(); + buildMinimap();updateCityHud();signState.key='';facadeState={entry:null,texture:null};renderer.setFacade(null); } // ---- City navigation: helicopter, walk and fly cameras, HUD, crosshair and minimap ---- @@ -580,7 +582,7 @@ function drawOverlay(){ resizeOverlay();const ctx=overlayCtx,w=overlay.clientWidth,h=overlay.clientHeight;ctx.clearRect(0,0,w,h);ctx.save();ctx.font='600 11px Inter, system-ui, sans-serif';ctx.textBaseline='top'; codeTexturesPending=false; codeTextureDeadline=performance.now()+5; - if(renderer.mode==='city'){visibleScreenItems=[];drawCityOverlay(ctx);ctx.restore();return;} + if(renderer.mode==='city'){visibleScreenItems=[];drawCityOverlay();ctx.restore();return;} const hitIds=new Set(searchHits.map(hit=>hit.entityId??hit.id)),occupied=[],visible=visibleLayout(w,h);visibleScreenItems=visible; let labelCount=0; if(showCode&&renderer.mode==='2d'){ @@ -613,16 +615,77 @@ function drawOverlay(){ ctx.restore(); } -function drawCityOverlay(ctx){ +function drawCityOverlay(){ + if(cityModel){updateCityReadouts();updateCitySigns();if(showCode)updateFacade();else renderer.setFacade(null);} + // The facade gets this frame's text budget before the background roof atlas. if(showCode){fillCodeOverview();uploadCodeOverview();} - if(!cityModel)return; - updateCityReadouts(); - const occupied=[],w=renderer.width,h=renderer.height;let count=0; - if(selected?.id){const b=layoutById.get(selected.id)?.city;if(b){const p=renderer.project([b.x+b.w/2,b.y+b.h/2,b.height+2]);if(p.x>=0&&p.x<=w&&p.y>=0&&p.y<=h)drawChip(ctx,selected.name,p.x,p.y-22,220,'#fff08a',occupied,true,'center');}} +} + +// ---- In-world text: ground names, roof plates, wall signs and the facade of the targeted file ---- +const labelAtlas=new LabelAtlas(); +let signState={key:'',at:0}; +const SIGN_LIMITS={roofs:260,walls:120,roofRadius:260,wallRadius:70}; + +function updateCitySigns(){ + const c=renderer.city,{eye}=renderer.cityPose(),heli=c.view==='heli'; + const focus=heli?[c.x,c.y]:[c.ex,c.ey],radius=heli?Math.max(SIGN_LIMITS.roofRadius,c.distance*1.3):SIGN_LIMITS.roofRadius; + // Rebuild when the focus moves a few metres, the view or selection changes, or the atlas was reset. + const yaw=heli?c.yaw:c.lookYaw; + const key=[Math.round(focus[0]/6),Math.round(focus[1]/6),Math.round(radius/40),c.view,selected?.id||0,labelAtlas.generation,Math.round(eye[2]/20),Math.round(yaw*10)].join(); + if(key===signState.key)return; + signState.key=key; + const signs={quads:[],full:false}; + buildSignQuads(signs,focus,radius,eye,heli,yaw); + if(signs.full){ + // Start a fresh atlas holding only what is needed here; anything that still does not fit is skipped. + labelAtlas.clear();signs.quads=[];signs.full=false;buildSignQuads(signs,focus,radius,eye,heli,yaw); + signState.key=[...key.split(',').slice(0,5),labelAtlas.generation,...key.split(',').slice(6)].join(); + } + renderer.setSigns(signs.quads,labelAtlas); +} + +// Nearest labels first, so if the atlas fills up only the most distant ones are lost. +function buildSignQuads(signs,focus,radius,eye,heli,yaw){ + const label=(text,style)=>{if(signs.full)return null;const entry=labelAtlas.get(text,style);if(!entry)signs.full=true;return entry;}; + const add=(quad,uv)=>signs.quads.push({...quad,uv}),right=heading(yaw).right; + // District names float above their tallest tower, turned toward the camera like skyline signs. + for(const district of cityDistricts){ + const entry=label(district.block.node.name,'plate');if(!entry)continue; + const b=district.block,width=Math.min(Math.max(b.w,b.h)*.5,Math.max(40,district.top*1.2)),height=width/entry.aspect; + add({c:[b.x+b.w/2,b.y+b.h/2,district.top+height/2+8],u:[right[0]*width/2,right[1]*width/2,0],v:[0,0,height/2]},entry); + } + const near=[...cityIndex.near(focus[0],focus[1],radius)].map(b=>({b,d:Math.hypot(b.x+b.w/2-focus[0],b.y+b.h/2-focus[1])})).filter(n=>n.d<=radius).sort((a,b)=>a.d-b.d); + const selectedCity=selected?.id?layoutById.get(selected.id)?.city:null; + if(selectedCity&&!near.some(n=>n.b===selectedCity))near.unshift({b:selectedCity,d:0}); + let roofs=0,walls=0; + for(const {b,d} of near){ + const isSelected=b===selectedCity; + if(roofs>=SIGN_LIMITS.roofs&&!isSelected&&(heli||d>=SIGN_LIMITS.wallRadius))break; + const entry=label(b.file.name,isSelected?'selected':'plate');if(!entry)break; + const height=Math.max(1.2,Math.min(Math.min(b.w,b.h)*.22,heli?12:5)),width=Math.min(b.w*.92,height*entry.aspect); + add(flatQuad(b.x+b.w/2,b.y+b.h/2,b.height+.08,width,width/entry.aspect),entry);roofs++; + if(!heli&&d=40)continue; - const p=renderer.project([block.x+block.w/2,block.y+block.h/2,0]);if(!(p.x>=0&&p.x<=w&&p.y>=0&&p.y<=h))continue; - if(drawChip(ctx,`${block.node.name}/`,p.x,p.y-8,180,'#8d7dff',occupied,false,'center'))count++; + if(block.depth<2||block.depth>3||Math.hypot(block.x+block.w/2-focus[0],block.y+block.h/2-focus[1])>radius)continue; + const entry=label(block.node.name,'ground');if(!entry)break; + const height=Math.min(block.depth===2?2.2:1.4,block.h*.12),width=Math.min(block.w*.7,height*entry.aspect); + add(flatQuad(block.x+block.w/2,block.y+height*.8,.4,width,width/entry.aspect),entry); + } +} + +// Source code on the wall of the building in the crosshair, once you are close enough to read it. +let facadeState={entry:null,texture:null}; +function updateFacade(){ + const c=renderer.city,entry=c.view!=='heli'?cityTargetEntry:null; + const eye=[c.ex,c.ey,c.ez],close=entry&&Math.hypot(entry.x+entry.w/2-eye[0],entry.y+entry.h/2-eye[1]) score) { score = facing; best = wall; } + } + if (!best) return null; + let width = best.width * .9, height = width / aspect; + const room = Math.max(1, (b.height - 1.5) * .92); + if (height > room) { height = room; width = height * aspect; } + return wallQuad(best, 1.2 + height / 2, width, height); +} diff --git a/web/labels.js b/web/labels.js new file mode 100644 index 0000000..3a84896 --- /dev/null +++ b/web/labels.js @@ -0,0 +1,77 @@ +// Text for the 3D city: labels are rasterised once into a shared atlas and drawn as textured quads, +// so signs are depth-tested against buildings instead of floating over them like DOM or 2D overlays. + +// Rows of fixed height filled left to right. add() returns a slot or null when the atlas is full. +export class ShelfPacker { + constructor(width, height, rowHeight) { Object.assign(this, { width, height, rowHeight }); this.reset(); } + reset() { this.x = 0; this.y = 0; } + add(width) { + width = Math.min(width, this.width); + if (this.x + width > this.width) { this.x = 0; this.y += this.rowHeight; } + if (this.y + this.rowHeight > this.height) return null; + const slot = { x: this.x, y: this.y, w: width, h: this.rowHeight }; + this.x += width + 2; + return slot; + } +} + +const STYLES = { + // Name plates on roofs and walls. + plate: { font: '600 30px Inter, system-ui, sans-serif', text: '#f1f3f8', fill: 'rgba(10,12,18,.9)', border: 'rgba(255,255,255,.14)' }, + selected: { font: '700 30px Inter, system-ui, sans-serif', text: '#1a1405', fill: 'rgba(255,236,140,.96)', border: 'rgba(255,255,255,.5)' }, + // District and street names painted on the ground. + ground: { font: '700 34px Inter, system-ui, sans-serif', text: 'rgba(205,198,255,.9)' }, +}; + +export class LabelAtlas { + constructor(width = 2048, height = 4096, rowHeight = 48) { + this.canvas = document.createElement('canvas'); + this.canvas.width = width; this.canvas.height = height; + this.ctx = this.canvas.getContext('2d'); + this.packer = new ShelfPacker(width, height, rowHeight); + this.entries = new Map(); + this.generation = 0; // bumps when the atlas is cleared and every entry is invalid + this.dirty = null; + } + + clear() { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.entries.clear(); this.packer.reset(); this.generation++; + this.dirty = { x0: 0, y0: 0, x1: this.canvas.width, y1: this.canvas.height }; + } + + // Atlas rectangle for text in a style: { u, v, du, dv, aspect }, or null if it cannot fit. + get(text, style = 'plate') { + const key = `${style}\n${text}`; + const cached = this.entries.get(key); + if (cached) return cached; + const s = STYLES[style], ctx = this.ctx, h = this.packer.rowHeight; + ctx.font = s.font; + const pad = s.fill ? 14 : 4, width = Math.ceil(Math.min(ctx.measureText(text).width + pad * 2, 900)); + const slot = this.packer.add(width); + if (!slot) return null; + ctx.save(); + ctx.beginPath(); ctx.rect(slot.x, slot.y, slot.w, slot.h); ctx.clip(); + if (s.fill) { + ctx.fillStyle = s.fill; ctx.strokeStyle = s.border; ctx.lineWidth = 2; + ctx.beginPath(); ctx.roundRect(slot.x + 1, slot.y + 3, slot.w - 2, h - 6, 12); ctx.fill(); ctx.stroke(); + } + ctx.fillStyle = s.text; ctx.textBaseline = 'middle'; + ctx.fillText(text, slot.x + pad, slot.y + h / 2 + 1, slot.w - pad * 2); + ctx.restore(); + const W = this.canvas.width, H = this.canvas.height; + const entry = { u: slot.x / W, v: slot.y / H, du: slot.w / W, dv: slot.h / H, aspect: slot.w / slot.h }; + this.entries.set(key, entry); + const d = this.dirty ||= { x0: slot.x, y0: slot.y, x1: slot.x, y1: slot.y }; + d.x0 = Math.min(d.x0, slot.x); d.y0 = Math.min(d.y0, slot.y); d.x1 = Math.max(d.x1, slot.x + slot.w); d.y1 = Math.max(d.y1, slot.y + slot.h); + return entry; + } + + // Pixels changed since the last call, as { x, y, image }, or null. + takeDirty() { + const d = this.dirty; if (!d) return null; + this.dirty = null; + const w = Math.max(1, d.x1 - d.x0), h = Math.max(1, d.y1 - d.y0); + return { x: d.x0, y: d.y0, image: this.ctx.getImageData(d.x0, d.y0, w, h) }; + } +} diff --git a/web/renderer.js b/web/renderer.js index 8701e24..6229508 100644 --- a/web/renderer.js +++ b/web/renderer.js @@ -123,6 +123,34 @@ precision highp float; out vec4 outColor; void main(){ outColor = vec4(.59, .63, 1.0, .11); }`; +// Textured quads for city signs and facades: centre, half-width axis, half-height axis, atlas rect. +const SIGN_VS = `#version 300 es +precision highp float; +layout(location=0) in vec2 aCorner; +layout(location=1) in vec3 aCenter; +layout(location=2) in vec3 aAxisU; +layout(location=3) in vec3 aAxisV; +layout(location=4) in vec4 aUv; +uniform mat4 uViewProj; +out vec2 vUv; +void main(){ + vec3 world = aCenter + aAxisU * (aCorner.x * 2.0 - 1.0) + aAxisV * (aCorner.y * 2.0 - 1.0); + vUv = aUv.xy + vec2(aCorner.x, 1.0 - aCorner.y) * aUv.zw; + gl_Position = uViewProj * vec4(world, 1.0); +}`; + +const SIGN_FS = `#version 300 es +precision highp float; +in vec2 vUv; +uniform sampler2D uTexture; +out vec4 outColor; +void main(){ + vec4 color = texture(uTexture, vUv); + if(color.a < .02) discard; + outColor = color; +}`; +const SIGN_FLOATS = 13; + function program(gl, vertex, fragment) { const value = gl.createProgram(); gl.attachShader(value, shader(gl, gl.VERTEX_SHADER, vertex)); @@ -163,6 +191,10 @@ export class LandscapeRenderer { this.anisotropy = gl.getExtension('EXT_texture_filter_anisotropic'); this.cube = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this.cube); gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW); 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.atlasTexture = this.texture(); this.facadeTexture = this.texture(); this.atlasGeneration = -1; this.camera = { x: 500, y: 350, zoom: 1, yaw: -.1, pitch: .78, distance: 3.5 }; // City camera: 'heli' orbits a ground target; 'walk' and 'fly' look out from an eye position. this.city = { view: 'heli', x: 0, y: 0, yaw: -.12, pitch: .8, distance: 500, ex: 0, ey: 0, ez: EYE_HEIGHT, lookYaw: 0, lookPitch: 0 }; @@ -181,6 +213,78 @@ export class LandscapeRenderer { return layer; } + quadLayer() { + const gl = this.gl, layer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 }; + gl.bindVertexArray(layer.vao); + gl.bindBuffer(gl.ARRAY_BUFFER, this.corners); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ARRAY_BUFFER, layer.buffer); + [[1, 3, 0], [2, 3, 12], [3, 3, 24], [4, 4, 36]].forEach(([index, size, offset]) => { + gl.enableVertexAttribArray(index); gl.vertexAttribPointer(index, size, gl.FLOAT, false, SIGN_FLOATS * 4, offset); gl.vertexAttribDivisor(index, 1); + }); + return layer; + } + + texture() { + const gl = this.gl, texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + return texture; + } + + // quads: [{ c, u, v, uv: {u, v, du, dv} }] from city.js placement helpers. + static packQuads(quads) { + const packed = new Float32Array(quads.length * SIGN_FLOATS); + quads.forEach((q, i) => packed.set([...q.c, ...q.u, ...q.v, q.uv.u, q.uv.v, q.uv.du, q.uv.dv], i * SIGN_FLOATS)); + return packed; + } + + setSigns(quads, atlas) { + this.atlas = atlas; + const gl = this.gl, packed = LandscapeRenderer.packQuads(quads); + gl.bindBuffer(gl.ARRAY_BUFFER, this.signLayer.buffer); gl.bufferData(gl.ARRAY_BUFFER, packed, gl.DYNAMIC_DRAW); + this.signLayer.count = quads.length; + } + + // Source code on one wall: a canvas and its quad, or null to hide. + setFacade(canvas, quad) { + const gl = this.gl; + if (!canvas || !quad) { this.facadeLayer.count = 0; return; } + if (canvas !== this.facadeSource) { + gl.bindTexture(gl.TEXTURE_2D, this.facadeTexture); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); gl.generateMipmap(gl.TEXTURE_2D); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); + this.facadeSource = canvas; + } + gl.bindBuffer(gl.ARRAY_BUFFER, this.facadeLayer.buffer); + gl.bufferData(gl.ARRAY_BUFFER, LandscapeRenderer.packQuads([{ ...quad, uv: { u: 0, v: 0, du: 1, dv: 1 } }]), gl.DYNAMIC_DRAW); + this.facadeLayer.count = 1; + } + + syncAtlas() { + const gl = this.gl, atlas = this.atlas; if (!atlas) return; + gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); + if (this.atlasGeneration === -1) { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlas.canvas); this.atlasGeneration = 0; atlas.takeDirty(); this.mipmapAtlas(); return; } + const dirty = atlas.takeDirty(); + if (dirty) { gl.texSubImage2D(gl.TEXTURE_2D, 0, dirty.x, dirty.y, gl.RGBA, gl.UNSIGNED_BYTE, dirty.image); this.mipmapAtlas(); } + } + + // Signs are mostly seen small and at grazing angles; without mipmaps the text shimmers. + mipmapAtlas() { + const gl = this.gl; + gl.generateMipmap(gl.TEXTURE_2D); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); + if (this.anisotropy) gl.texParameterf(gl.TEXTURE_2D, this.anisotropy.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(8, gl.getParameter(this.anisotropy.MAX_TEXTURE_MAX_ANISOTROPY_EXT))); + } + + drawQuads(layer, texture) { + if (!layer.count) return; + const gl = this.gl, u = this.signUniforms; + gl.useProgram(this.signProgram); gl.bindVertexArray(layer.vao); + gl.uniformMatrix4fv(u.uViewProj, false, this.viewProj); + gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texture); gl.uniform1i(u.uTexture, 0); + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, layer.count); + } + upload(layer, packed) { const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, layer.buffer); gl.bufferData(gl.ARRAY_BUFFER, packed, gl.DYNAMIC_DRAW); layer.count = packed.length / FLOATS_PER_INSTANCE; layer.packed = packed; @@ -288,6 +392,15 @@ export class LandscapeRenderer { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.codeTexture); gl.uniform1i(u.uCode, 0); gl.uniform1f(u.uCodeOn, this.codeOn && this.mode !== '2d' ? 1 : 0); gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, layer.count); + if (this.mode === 'city') { + // Signs sit on or just off surfaces: test depth, don't write it, and pull them forward a touch. + 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.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); + } } // Geometry of an item in the active world: map units for 2D/3D, metres in the city. diff --git a/webtest/labels.test.mjs b/webtest/labels.test.mjs new file mode 100644 index 0000000..8ab0c9b --- /dev/null +++ b/webtest/labels.test.mjs @@ -0,0 +1,42 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ShelfPacker } from '../web/labels.js'; +import { flatQuad, wallSigns, facadeQuad } from '../web/city.js'; + +test('shelf packer fills rows then reports full', () => { + const packer = new ShelfPacker(100, 20, 10); + assert.deepEqual(packer.add(60), { x: 0, y: 0, w: 60, h: 10 }); + assert.deepEqual(packer.add(60), { x: 0, y: 10, w: 60, h: 10 }); + assert.deepEqual(packer.add(30), { x: 62, y: 10, w: 30, h: 10 }); + assert.equal(packer.add(30), null); + packer.reset(); + assert.deepEqual(packer.add(500), { x: 0, y: 0, w: 100, h: 10 }); +}); + +const building = { x: 0, y: 0, w: 20, h: 10, height: 30 }; +const cross = (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]]; + +test('wall signs only appear on walls facing the viewer and read left to right', () => { + const south = wallSigns(building, [10, 50, 1.7], 4); + assert.equal(south.length, 1); + assert.ok(south[0].c[1] > 10 && south[0].u[0] > 0, 'south wall reads along +x for a viewer looking north'); + const corner = wallSigns(building, [40, 40, 1.7], 4); + assert.equal(corner.length, 2); + for (const quad of corner) { + // u x v points out of the wall toward the viewer when text reads correctly (left-handed world). + const normal = cross(quad.u, quad.v), toEye = [40 - quad.c[0], 40 - quad.c[1], 0]; + assert.ok(normal[0] * toEye[0] + normal[1] * toEye[1] < 0); + } + assert.ok(wallSigns(building, [10, 50, 1.7], 100)[0].u[0] * 2 <= 20 * .85 + 1e-9, 'long names shrink to the wall'); + const long = wallSigns({ x: 0, y: 0, w: 100, h: 10, height: 30 }, [50, 50, 1.7], 4); + assert.equal(long.length, 4, 'a 100 m wall repeats its sign'); + assert.ok(long.every(quad => quad.c[1] > 10) && new Set(long.map(quad => Math.round(quad.c[0]))).size === 4); +}); + +test('facade covers the most squarely facing wall within the building', () => { + const quad = facadeQuad(building, [60, 5, 1.7], .5); + assert.ok(quad.c[0] > 20, 'east wall'); + const height = quad.v[2] * 2, width = Math.hypot(quad.u[0], quad.u[1]) * 2; + assert.ok(Math.abs(width / height - .5) < 1e-9 && quad.c[2] + height / 2 <= building.height); + assert.deepEqual(flatQuad(1, 2, 3, 4, 2), { c: [1, 2, 3], u: [2, 0, 0], v: [0, -1, 0] }); +});