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] }); +});