diff --git a/scripts/build-basel-panorama.mjs b/scripts/build-basel-panorama.mjs new file mode 100644 index 0000000..3ce02ad --- /dev/null +++ b/scripts/build-basel-panorama.mjs @@ -0,0 +1,190 @@ +/** + * Erzeugt das Basler Panorama für den 3D-Meetingraum (Runde 6, §2). + * + * Vorher lagen zwei getrennte Fotoflächen vor den Glasfronten — vorne und + * links. An deren Stossstelle war auf der linken Fensterseite eine harte Kante + * sichtbar. Diese Naht lässt sich nicht wegblenden; sie verschwindet nur, wenn + * es gar keine zwei Bilder mehr gibt. + * + * Dieses Skript baut aus den beiden Referenzfotos des Auftrags eine einzige + * durchgehende Panoramadatei. Jede Quelle wird **genau einmal** verwendet — + * kein Kacheln, kein Spiegeln (§2.2). Das gut wirkende Motiv vom Tischende + * («front») bildet die linke Hälfte und bleibt in Ausschnitt und Farbe + * unverändert; die Panoramaaufnahme («left») setzt es nach links fort, in + * Helligkeit angeglichen und über 300 Pixel weich überblendet. + * + * Himmel und Wasser werden anschliessend aus der jeweiligen Randzeile nach oben + * und unten gestreckt. Ohne das stünde über und unter dem Bildstreifen eine + * harte Grenze, die im Raum als Kante sichtbar wäre. + * + * Einmalig auszuführen; das Ergebnis liegt als Asset im Repository: + * + * node scripts/build-basel-panorama.mjs + * + * Es braucht keine Bildbibliothek — die Komposition entsteht auf einem Canvas + * im bereits vorhandenen Chrome, den auch die visuellen Prüfungen nutzen. + */ + +import { chromium } from 'playwright-core' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'node:path' + +const here = dirname(fileURLToPath(import.meta.url)) +const assets = resolve(here, '../src/assets/staff') + +const SRC_FRONT = resolve(assets, 'basel-front.jpg') +const SRC_LEFT = resolve(assets, 'basel-left.jpg') +const OUT = resolve(assets, 'basel-panorama.jpg') + +/** + * Zielmasse. Die Breite ergibt sich aus den beiden Quellen: jede einmal, + * abzüglich der Überblendung. Grösser wäre reines Hochskalieren. + */ +const H = 660 +/** Wasserlinie im Ziel — beide Quellen werden darauf ausgerichtet. */ +const WATER_Y = 400 +/** Wasserlinie in den Quellen, als Anteil der Bildhöhe. */ +const FRONT_WATER = 0.60 +const LEFT_WATER = 0.64 +/** Skalierung der beiden Quellen. */ +const FRONT_SCALE = 1.3 +const LEFT_SCALE = 2.45 + +/** + * Ausschnitt der Fortsetzung, in Pixeln der Quelle. + * + * Beide Referenzfotos zeigen das Münster. Nebeneinander gesetzt stünde es + * zweimal im Bild — eine Doppelstruktur, die §2.1 ausdrücklich ausschliesst. + * Aus der Panoramaaufnahme wird deshalb nur der Abschnitt östlich des Münsters + * verwendet: die Häuserzeilen am Rhein. Das Münster selbst kommt genau einmal + * vor, nämlich im Mittelmotiv vom Tischende. + */ +const LEFT_CROP = { sx: 330, sw: 370 } +/** Breite der Überblendung zwischen Fortsetzung und Mittelmotiv. */ +const FEATHER = 320 + +const CHROME = [ + process.env.CHROME_PATH, + 'C:/Program Files/Google/Chrome/Application/chrome.exe', + 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe', +].filter(Boolean).find(p => existsSync(p)) + +if (!CHROME) throw new Error('Kein Chrome gefunden — CHROME_PATH setzen.') +for (const f of [SRC_FRONT, SRC_LEFT]) { + if (!existsSync(f)) throw new Error(`Referenzbild fehlt: ${f}`) +} + +const asDataUri = f => `data:image/jpeg;base64,${readFileSync(f).toString('base64')}` + +const browser = await chromium.launch({ executablePath: CHROME }) +const page = await browser.newPage() + +const result = await page.evaluate(async (cfg) => { + const load = src => new Promise((ok, err) => { + const img = new Image() + img.onload = () => ok(img) + img.onerror = err + img.src = src + }) + const [fImg, lImg] = await Promise.all([load(cfg.front), load(cfg.left)]) + + const fW = Math.round(fImg.width * cfg.FRONT_SCALE) + const fH = Math.round(fImg.height * cfg.FRONT_SCALE) + const lW = Math.round(cfg.LEFT_CROP.sw * cfg.LEFT_SCALE) + const lH = Math.round(lImg.height * cfg.LEFT_SCALE) + + const W = lW + fW - cfg.FEATHER + const H = cfg.H + + /** Mittlere Helligkeit eines Quellbereichs. */ + function meanLuma(img, sy, sh) { + const c = document.createElement('canvas') + c.width = 48 + c.height = 48 + const cg = c.getContext('2d') + cg.drawImage(img, 0, sy, img.width, sh, 0, 0, 48, 48) + const d = cg.getImageData(0, 0, 48, 48).data + let sum = 0 + for (let i = 0; i < d.length; i += 4) sum += 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2] + return sum / (d.length / 4) + } + + // Helligkeit der Fortsetzung an das Mittelmotiv angleichen. Ohne diesen + // Abgleich bliebe trotz Überblendung ein Helligkeitssprung sichtbar. + const bandF = meanLuma(fImg, fImg.height * 0.3, fImg.height * 0.45) + const bandL = meanLuma(lImg, lImg.height * 0.3, lImg.height * 0.45) + const korrektur = Math.max(0.8, Math.min(1.25, bandF / bandL)) + + /** + * Eine Quelle als Schicht über die volle Zielhöhe: das Foto an der + * Wasserlinie ausgerichtet, darüber der Himmel und darunter das Wasser aus der + * jeweiligen Randzeile gestreckt. So deckt jede Schicht die ganze Höhe ab und + * es entsteht keine harte obere oder untere Bildgrenze. + */ + function schicht(img, crop, scale, waterFraction, brightness) { + const sx = crop ? crop.sx : 0 + const sw = crop ? crop.sw : img.width + const w = Math.round(sw * scale) + const h = Math.round(img.height * scale) + const y = Math.round(cfg.WATER_Y - img.height * waterFraction * scale) + + const c = document.createElement('canvas') + c.width = w + c.height = H + const cg = c.getContext('2d') + cg.imageSmoothingEnabled = true + cg.imageSmoothingQuality = 'high' + cg.save() + if (brightness !== 1) cg.filter = `brightness(${brightness.toFixed(3)})` + cg.drawImage(img, sx, 0, sw, img.height, 0, y, w, h) + cg.restore() + // Himmel nach oben, Wasser nach unten strecken + if (y > 0) cg.drawImage(c, 0, y, w, 2, 0, 0, w, y + 2) + const unten = y + h + if (unten < H) cg.drawImage(c, 0, unten - 2, w, 2, 0, unten - 2, w, H - unten + 2) + return { canvas: c, w } + } + + const cv = document.createElement('canvas') + cv.width = W + cv.height = H + const g = cv.getContext('2d') + g.imageSmoothingEnabled = true + g.imageSmoothingQuality = 'high' + + // 1) Fortsetzung rechts, einmal verwendet, helligkeitsangeglichen. Der + // Zylinder beginnt beim Mittelmotiv und läuft nach links in den Raum + // weiter; die Bildfolge muss dieser Richtung folgen. + const fort = schicht(lImg, cfg.LEFT_CROP, cfg.LEFT_SCALE, cfg.LEFT_WATER, korrektur) + g.drawImage(fort.canvas, W - fort.w, 0) + + // 2) Mittelmotiv links, unverändert, mit weicher rechter Flanke. + const vorne = schicht(fImg, null, cfg.FRONT_SCALE, cfg.FRONT_WATER, 1) + const mid = document.createElement('canvas') + mid.width = vorne.w + mid.height = H + const mg = mid.getContext('2d') + mg.drawImage(vorne.canvas, 0, 0) + mg.globalCompositeOperation = 'destination-in' + const mGrad = mg.createLinearGradient(0, 0, vorne.w, 0) + mGrad.addColorStop(0, 'rgba(0,0,0,1)') + mGrad.addColorStop(1 - cfg.FEATHER / vorne.w, 'rgba(0,0,0,1)') + mGrad.addColorStop(1, 'rgba(0,0,0,0)') + mg.fillStyle = mGrad + mg.fillRect(0, 0, vorne.w, H) + g.drawImage(mid, 0, 0) + + return { dataUri: cv.toDataURL('image/jpeg', 0.84), W, H, korrektur } +}, { + front: asDataUri(SRC_FRONT), + left: asDataUri(SRC_LEFT), + H, WATER_Y, FRONT_WATER, LEFT_WATER, FRONT_SCALE, LEFT_SCALE, FEATHER, LEFT_CROP, +}) + +await browser.close() + +writeFileSync(OUT, Buffer.from(result.dataUri.split(',')[1], 'base64')) +const kb = (readFileSync(OUT).length / 1024).toFixed(0) +console.log(`Panorama erzeugt: ${OUT}`) +console.log(` ${result.W}x${result.H}, ${kb} KB, Helligkeitsangleichung ×${result.korrektur.toFixed(3)}`) diff --git a/src/assets/staff/basel-panorama.jpg b/src/assets/staff/basel-panorama.jpg new file mode 100644 index 0000000..b4dd62f Binary files /dev/null and b/src/assets/staff/basel-panorama.jpg differ diff --git a/src/components/layout/GlobalNavMenu.tsx b/src/components/layout/GlobalNavMenu.tsx index da1f489..9c32d6b 100644 --- a/src/components/layout/GlobalNavMenu.tsx +++ b/src/components/layout/GlobalNavMenu.tsx @@ -37,6 +37,9 @@ const ROLE_LABELS: Record = { */ export function GlobalNavMenu() { const [anchorEl, setAnchorEl] = useState(null) + // Beim Öffnen festgehaltene Ankerstelle: oben unter der Kopfzeile, rechts + // exakt an der Fensterkante. + const [ankerStelle, setAnkerStelle] = useState({ top: 64, left: 0 }) const { currentUser, logout } = useSessionStore() const navigate = useNavigate() const showToast = useToastStore(s => s.showToast) @@ -61,7 +64,12 @@ export function GlobalNavMenu() { setAnchorEl(open ? null : e.currentTarget)} + onClick={e => { + if (open) { setAnchorEl(null); return } + const b = e.currentTarget.getBoundingClientRect() + setAnkerStelle({ top: b.bottom + 8, left: document.documentElement.clientWidth }) + setAnchorEl(e.currentTarget) + }} aria-label="Menü öffnen" aria-haspopup="true" aria-expanded={open} @@ -75,12 +83,22 @@ export function GlobalNavMenu() { open={open} anchorEl={anchorEl} onClose={close} - anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} + /* + * An eine Bildschirmstelle statt an das Symbol gebunden: das Symbol liegt + * um den Innenabstand der Kopfzeile eingerückt, und ein daran + * ausgerichtetes Menü erbte genau diesen Abstand als Spalt zur rechten + * Fensterkante (§5.1). `marginThreshold={0}` hebt zusätzlich den + * Sicherheitsabstand auf, den MUI sonst zum Fensterrand hält. + * Gerechnet wird mit `clientWidth` statt `innerWidth`, also ohne den + * Scrollbalken — sonst entstünde ein waagrechter Scrollbalken. + */ + anchorReference="anchorPosition" + anchorPosition={ankerStelle} transformOrigin={{ horizontal: 'right', vertical: 'top' }} + marginThreshold={0} slotProps={{ paper: { sx: { - mt: 1, width: 320, borderRadius: 2, boxShadow: '0 18px 50px rgba(15,25,35,0.22)', diff --git a/src/components/staff/MeetingRoom3D.tsx b/src/components/staff/MeetingRoom3D.tsx index 5d2cc16..be01f90 100644 --- a/src/components/staff/MeetingRoom3D.tsx +++ b/src/components/staff/MeetingRoom3D.tsx @@ -8,8 +8,7 @@ import { MEETING_SEATS } from './meetingRoomSeats' import { AGENT_PHOTOS } from '../team/agentPhotos' import { useAgentWorkload } from '../../hooks/useAgentWorkload' import { WorkloadTone } from '../../domain/agentWorkload' -import { DS_ACCENT } from '../../lib/ds' -import { DS_NEUTRAL, DS_TEXT } from '../../lib/ds' +import { DS_ACCENT, DS_NEUTRAL } from '../../lib/ds' /** * Farben der Auslastung auf dunklem Grund. Hellere Stufen als die @@ -328,15 +327,6 @@ export function MeetingRoom3D() { ))} - - Kleinbasel · Blick über den Rhein nach Grossbasel - ) } diff --git a/src/components/staff/meetingRoomPalette.ts b/src/components/staff/meetingRoomPalette.ts index 9fcf1ec..6dc0363 100644 --- a/src/components/staff/meetingRoomPalette.ts +++ b/src/components/staff/meetingRoomPalette.ts @@ -51,18 +51,18 @@ export const ROOM_COLORS = { export const ROOM = { width: 7.4, height: 3.05, - depth: 10.6, + depth: 9.2, } as const /** Kamera: Augenhöhe einer sitzenden Person am Kopfende (§2). */ export const CAMERA = { /** Sitzende Augenhöhe über dem Boden. */ - eyeHeight: 1.38, + eyeHeight: 1.19, /** Knapp hinter der vorderen Tischkante. */ z: 1.9, fov: 48, /** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */ - lookAt: [0, 1.06, -6.2] as const, + lookAt: [0, 1.24, -5.2] as const, /** * Maximaler Kameraversatz der Parallaxe in Metern. Bewusst winzig: die * Perspektive ist verbindlich, die Bewegung darf sie nur beleben (§20). @@ -72,21 +72,29 @@ export const CAMERA = { /** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */ export const TABLE = { - width: 1.62, + width: 1.70, /** Höhe der Plattenoberseite über dem Boden. */ top: 0.745, thickness: 0.06, /** Vordere Kante, knapp vor der Kamera. */ zNear: 0.15, /** Hintere Kante, vor dem Platz von Thomas. */ - zFar: -7.35, + zFar: -5.75, } as const /** Sitzende Personen: Kopfhöhe und Grössen. */ export const SEATED = { - /** Kopfmitte über dem Boden — deutlich über der Tischplatte. */ - headY: 1.30, - headRadius: 0.155, + /** + * Höhe der Porträtkreise über dem Boden. + * + * Bewusst deutlich über der Blickachse der Kamera (1.19 m). Lagen die Kreise + * auf Augenhöhe, projizierten alle fünf auf fast dieselbe Bildhöhe und die + * hinteren verschwanden hinter den vorderen — unabhängig davon, wie weit man + * sie seitlich auseinanderzog. Erst der Höhenunterschied zur Blickachse + * trennt sie in der Tiefe (§3.2). + */ + headY: 1.52, + headRadius: 0.142, /** Torso vom Stuhl bis zum Hals. */ torsoTop: 1.12, torsoBottom: 0.42, diff --git a/src/components/staff/meetingRoomScene.ts b/src/components/staff/meetingRoomScene.ts index e1c0577..9b6cfd9 100644 --- a/src/components/staff/meetingRoomScene.ts +++ b/src/components/staff/meetingRoomScene.ts @@ -2,8 +2,7 @@ import * as THREE from 'three' import { ROOM, ROOM_COLORS, CAMERA, TABLE, SEATED } from './meetingRoomPalette' import { SeatSide } from './meetingRoomSeats' import type { MeetingSeat } from './meetingRoomSeats' -import baselFront from '../../assets/staff/basel-front.jpg' -import baselLeft from '../../assets/staff/basel-left.jpg' +import baselPanorama from '../../assets/staff/basel-panorama.jpg' /** * Aufbau des virtuellen Meetingraums (Runde 6, §2–§6). @@ -129,46 +128,63 @@ function addRoomShell(scene: THREE.Scene) { } /** - * Basler Fernkulisse (§4). + * Basler Fernkulisse (Runde 6, §2). * - * Zwei grosse Flächen weit aussen vor den Glasfronten, texturiert mit den beiden - * Referenzfotos aus dem Auftrag. Sie stehen so weit weg, dass die Perspektive - * sie als Ferne liest und die Fensterpfosten davor sauber schneiden. + * **Eine** gekrümmte Fläche mit **einem** Panoramabild — nicht mehr zwei + * Flächen mit zwei Fotos. Genau deren Stossstelle war auf der linken + * Fensterseite als harte Kante sichtbar. Auf einem durchgehenden Zylinder gibt + * es diese Stelle nicht mehr: der Blick nach vorne und der Blick nach links + * lesen dasselbe Bild an benachbarten Stellen, eine Naht ist geometrisch + * unmöglich. * - * Beide Fotos zeigen dieselbe Rheinseite von derselben Uferseite. Damit der - * Blick nach vorne und der Blick nach links zusammengehören, liegt die - * Wasserlinie beider Kulissen auf derselben Welthöhe — sonst wirkte es wie zwei - * zufällig zusammengesetzte Bilder. + * Das Panorama entsteht einmalig aus den beiden Referenzfotos des Auftrags; + * siehe `scripts/build-basel-panorama.mjs`. Das Motiv vom Tischende bleibt darin + * unverändert und wird nach links fortgeführt. + * + * Die Zahlen sind so gewählt, dass Bildmasstab und Wasserlinie denen der + * früheren Frontfläche entsprechen — das gut wirkende Motiv am Tischende + * erscheint dadurch unverändert gross (§2.2). */ -function addBaselBackdrop(scene: THREE.Scene, loader: THREE.TextureLoader) { +function addBaselBackdrop(scene: THREE.Scene, loader: THREE.TextureLoader, maxAnisotropy: number) { + /** Abstand der Kulisse. Weit genug, dass die Perspektive sie als Ferne liest. */ + const radius = 62 + /** + * Abgedeckter Bogen. Er beginnt rechts neben der Frontscheibe und läuft über + * die Front nach links bis hinter die linke Glasfassade — mehr braucht es + * nicht, und weniger Bogen heisst mehr Bildpunkte je Grad. + */ + const arc = 2.62 + /** Höhe passend zum Seitenverhältnis des Panoramas (1523 × 660). */ + const height = (radius * arc) / (1523 / 660) + /** Wasserlinie im Panorama, als Anteil von oben. */ + const waterFraction = 400 / 660 /** Höhe der Rheinoberfläche relativ zum Bürofussboden — wir sind im Hochhaus. */ const waterY = -7.5 - function backdrop(url: string, waterFraction: number, w: number, h: number) { - const tex = loader.load(url) - tex.colorSpace = THREE.SRGBColorSpace - const mesh = new THREE.Mesh( - new THREE.PlaneGeometry(w, h), - // Unbeleuchtet: das Foto trägt seine eigene Belichtung, jede Beleuchtung - // würde es nur verfälschen. - new THREE.MeshBasicMaterial({ map: tex }), - ) - // Mittelpunkt so setzen, dass die Wasserlinie des Fotos auf `waterY` liegt. - mesh.position.y = waterY + h * (waterFraction - 0.5) - return mesh - } + const tex = loader.load(baselPanorama) + tex.colorSpace = THREE.SRGBColorSpace + tex.anisotropy = maxAnisotropy + // Kein Wiederholen: der Bogen deckt genau die Bildbreite ab, und eine + // Wiederholung wäre als Doppelung sichtbar (§2.2). + tex.wrapS = THREE.ClampToEdgeWrapping + tex.wrapT = THREE.ClampToEdgeWrapping - // Vorne: Blick über den Rhein auf Altstadt und Münster. Wasserlinie bei ~60 %. - const front = backdrop(baselFront, 0.6, 70, 47) - front.position.z = -58 - scene.add(front) - - // Links: Rheinverlauf mit Altstadtsilhouette. Panoramaformat, Wasserlinie ~64 %. - const left = backdrop(baselLeft, 0.64, 118, 38) - left.rotation.y = Math.PI / 2 - left.position.x = -56 - left.position.z = -26 - scene.add(left) + const mesh = new THREE.Mesh( + new THREE.CylinderGeometry(radius, radius, height, 96, 1, true, 0, arc), + // Unbeleuchtet und von innen sichtbar: das Foto trägt seine eigene + // Belichtung, jede Beleuchtung würde sie nur verfälschen. + new THREE.MeshBasicMaterial({ map: tex, side: THREE.BackSide, fog: false }), + ) + // Mittelpunkt so setzen, dass die Wasserlinie des Panoramas auf `waterY` liegt. + mesh.position.y = waterY - height * (0.5 - waterFraction) + /** + * Drehung: geradeaus sichtbar ist die Bildstelle u = (π − Drehung) / Bogen. + * Der Abzug von 0.68 setzt damit u ≈ 0.26 in die Blickachse — dort steht im + * Panorama das Münster. Die Frontscheibe zeigt so das gute Motiv vom + * Tischende, die linke Fassade seine Fortsetzung nach links. + */ + mesh.rotation.y = Math.PI - 0.68 + scene.add(mesh) } /** Bodentiefe Glasfassade mit Pfosten — links und gegenüber (§3). */ @@ -264,122 +280,92 @@ function addTable(scene: THREE.Scene) { } /** - * Ein sitzender Agent (§6). + * Ein Agent am Tisch (§3). * - * Kopf ist eine Kreisfläche mit dem bestehenden Porträt als Textur — die - * Kreisgeometrie beschneidet das quadratische Foto rund, genau wie die Avatare - * im übrigen Programm. Darunter ein Torso in Anzugfarbe und eine Stuhllehne. + * Nur das runde Porträt mit einem weissen Ring — keine Stühle, keine Körper, + * keine Arme. Die früheren Sitzmöbel und Rümpfe wirkten unnatürlich und sind + * ersatzlos entfallen (§3.1). * - * Der Kopf ist um `yaw` zum Tisch gedreht: die Agenten schauen ins Geschehen - * (§2), bleiben dabei aber lesbar. Voll zur Tischmitte gedreht sähe man von - * einem frontalen Porträtfoto nur die Kante. + * Das Porträt hängt in einer Kreisfläche. Weil die Vorlagen unterschiedliche + * Seitenverhältnisse haben, wird die Textur auf einen mittigen quadratischen + * Ausschnitt begrenzt: ohne diese Beschneidung zieht die Kreisgeometrie ein + * liegendes Foto in die Breite, was Gesichter verzerrt und Schärfe kostet (§3.3). + * + * Das Porträt schaut zum Betrachter. Voll zur Tischmitte gedreht sähe man von + * einem frontalen Porträtfoto nur die Kante; eine leichte Drehung zum Tisch + * genügt, damit die Runde zusammengehört. */ function addSeatedAgent( scene: THREE.Scene, loader: THREE.TextureLoader, seat: MeetingSeat, photoUrl: string | undefined, - suitColor: number, + maxAnisotropy: number, ): SeatHandle { const group = new THREE.Group() group.position.set(seat.x, 0, seat.z) - // Blickrichtung: zum Kopfende des Betrachters hin, aber zur Tischmitte gedreht. const yaw = seat.side === SeatSide.HEAD ? 0 - : seat.side === SeatSide.LEFT ? 0.62 - : -0.62 + : seat.side === SeatSide.LEFT ? 0.42 + : -0.42 - // ── Torso ── - const torsoHeight = SEATED.torsoTop - SEATED.torsoBottom - const torso = new THREE.Mesh( - new THREE.CylinderGeometry(SEATED.torsoRadiusTop, SEATED.torsoRadiusBottom, torsoHeight, 20, 1), - new THREE.MeshLambertMaterial({ color: suitColor }), - ) - torso.position.y = SEATED.torsoBottom + torsoHeight / 2 - group.add(torso) - - // Schultern: eine flach gedrückte Kugel schliesst den Torso oben ab - const shoulders = new THREE.Mesh( - new THREE.SphereGeometry(SEATED.shoulderWidth / 2, 20, 12), - new THREE.MeshLambertMaterial({ color: suitColor }), - ) - shoulders.scale.set(1, 0.42, 0.66) - shoulders.position.y = SEATED.torsoTop - 0.02 - group.add(shoulders) - - // ── Kopf ── const head = new THREE.Group() head.position.y = SEATED.headY head.rotation.y = yaw const ring = new THREE.Mesh( - new THREE.CircleGeometry(SEATED.headRadius + 0.018, 40), + new THREE.CircleGeometry(SEATED.headRadius + 0.016, 48), new THREE.MeshBasicMaterial({ color: ROOM_COLORS.headRing }), ) ring.position.z = -0.004 head.add(ring) if (photoUrl) { - const tex = loader.load(photoUrl) + const tex = loader.load(photoUrl, t => { + // Mittigen quadratischen Ausschnitt wählen, sobald die Masse bekannt sind. + const { width, height } = t.image as { width: number; height: number } + if (width > height) { + t.repeat.set(height / width, 1) + t.offset.set((1 - height / width) / 2, 0) + } else if (height > width) { + t.repeat.set(1, width / height) + t.offset.set(0, (1 - width / height) / 2) + } + t.needsUpdate = true + }) tex.colorSpace = THREE.SRGBColorSpace + // Anisotrope Filterung: die Kreise stehen schräg im Raum, ohne sie werden + // die Porträts an den Rändern matschig. + tex.anisotropy = maxAnisotropy + tex.generateMipmaps = true + tex.minFilter = THREE.LinearMipmapLinearFilter + tex.magFilter = THREE.LinearFilter + const face = new THREE.Mesh( - new THREE.CircleGeometry(SEATED.headRadius, 40), + new THREE.CircleGeometry(SEATED.headRadius, 48), new THREE.MeshBasicMaterial({ map: tex }), ) head.add(face) } - // Hinterkopf, damit von der Seite kein Loch entsteht - const backOfHead = new THREE.Mesh( - new THREE.SphereGeometry(SEATED.headRadius * 0.94, 20, 14), - new THREE.MeshLambertMaterial({ color: 0x2a2724 }), - ) - backOfHead.position.z = -SEATED.headRadius * 1.05 - backOfHead.scale.set(1, 1, 0.62) - head.add(backOfHead) - - // Hals - const neck = new THREE.Mesh( - new THREE.CylinderGeometry(0.055, 0.07, 0.16, 12), - new THREE.MeshLambertMaterial({ color: 0xc79a7c }), - ) - neck.position.y = SEATED.headY - 0.17 - group.add(neck) - group.add(head) - // Unterarme auf der Platte. Sie kosten zwei Quader und sind der Unterschied - // zwischen einem Rumpf hinter einer Kante und einer Person, die am Tisch sitzt. - const armMat = new THREE.MeshLambertMaterial({ color: suitColor }) - for (const sign of [-1, 1]) { - const arm = new THREE.Mesh(new THREE.BoxGeometry(0.085, 0.075, 0.42), armMat) - arm.position.set(0, TABLE.top + 0.045, 0) - arm.rotation.y = yaw - arm.translateX(sign * 0.16) - // Zur Tischmitte hin nach vorne gelegt - arm.translateZ(seat.side === SeatSide.HEAD ? 0.3 : 0.3) - group.add(arm) - } - - // ── Stuhllehne hinter dem Agenten ── - const chair = new THREE.Mesh( - new THREE.BoxGeometry(0.56, 0.62, 0.06), - new THREE.MeshLambertMaterial({ color: ROOM_COLORS.chair }), - ) - chair.position.set(0, 0.86, 0) - chair.rotation.y = yaw - chair.translateZ(-0.28) - group.add(chair) - // ── Trefferkörper: gross genug für bequemes Klicken (§9) ── const hitBox = new THREE.Mesh( - new THREE.BoxGeometry(0.94, 1.34, 0.82), + /* + * Eine Kugel knapp um den sichtbaren Kreis — nicht ein grosszügiger Quader. + * Ein weiter Quader am vorderen Platz fing die Strahlen der hinteren Plätze + * ab: der Raycaster nimmt den nächstgelegenen Treffer, und damit landete ein + * Klick auf Livia bei Ferdi. Die Trefferfläche deckt sich jetzt mit dem, was + * man sieht. + */ + new THREE.SphereGeometry(SEATED.headRadius * 1.18, 12, 8), // Unsichtbar, aber weiterhin vom Raycaster erfasst: `visible = false` würde // das Objekt aus der Strahlprüfung nehmen. new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }), ) - hitBox.position.y = 0.9 + hitBox.position.y = SEATED.headY group.add(hitBox) scene.add(group) @@ -415,7 +401,7 @@ export function createMeetingRoomScene( scene.background = new THREE.Color(ROOM_COLORS.sky) // Leichter Dunst nur in der Ferne: der Raum (0…12 m) bleibt klar, die Kulisse // bei 40–65 m bekommt Tiefe. Ohne ihn klebt die Stadt an der Scheibe. - scene.fog = new THREE.Fog(ROOM_COLORS.sky, 30, 110) + scene.fog = new THREE.Fog(ROOM_COLORS.sky, 40, 140) const camera = new THREE.PerspectiveCamera(CAMERA.fov, 1, 0.1, 300) camera.position.set(0, CAMERA.eyeHeight, CAMERA.z) @@ -432,14 +418,15 @@ export function createMeetingRoomScene( scene.add(bounce) const loader = new THREE.TextureLoader() + const maxAnisotropy = renderer.capabilities.getMaxAnisotropy() - addBaselBackdrop(scene, loader) + addBaselBackdrop(scene, loader, maxAnisotropy) addRoomShell(scene) addGlassFacades(scene) addTable(scene) - const handles = seats.map((seat, i) => - addSeatedAgent(scene, loader, seat, photos[seat.id], ROOM_COLORS.suits[i % ROOM_COLORS.suits.length]), + const handles = seats.map(seat => + addSeatedAgent(scene, loader, seat, photos[seat.id], maxAnisotropy), ) const baseCam = camera.position.clone() diff --git a/src/components/staff/meetingRoomSeats.ts b/src/components/staff/meetingRoomSeats.ts index d3c3804..c1c60db 100644 --- a/src/components/staff/meetingRoomSeats.ts +++ b/src/components/staff/meetingRoomSeats.ts @@ -42,33 +42,40 @@ export interface MeetingSeat { } /** - * Abstand der Plätze entlang des Tisches, in Metern. Bewusst grosszügig: bei - * engerer Staffelung überschneiden sich in der Perspektive die Köpfe der - * hinteren Plätze mit denen der vorderen (§5 «keine Personen überlappen»). + * Sitzplätze der fünf Agenten, in Metern (Runde 6, §3.2). + * + * Ferdi und Bruno sind die direkten Nachbarn des Betrachters — einer links, + * einer rechts, schräg frontal und dicht an der vorderen Tischkante. Dadurch + * entsteht hinter ihnen Platz, in dem die übrigen drei gestaffelt Raum haben, + * ohne sich zu überlagern. + * + * Vorher standen alle fünf zu weit hinten und drängten sich am Fluchtpunkt. */ -const SEAT_Z = [-1.75, -3.55, -5.35] +const PLAETZE: Record = { + ferdi: { side: SeatSide.LEFT, x: -1.14, z: -1.20 }, + bruno: { side: SeatSide.RIGHT, x: 1.14, z: -1.20 }, + livia: { side: SeatSide.LEFT, x: -1.42, z: -2.90 }, + nora: { side: SeatSide.RIGHT, x: 1.42, z: -2.90 }, + sina: { side: SeatSide.LEFT, x: -1.30, z: -4.75 }, +} -/** Seitlicher Abstand von der Tischmitte — knapp aussen an der Tischkante. */ -const SEAT_X = 1.32 +/** + * Notplatz für einen Agenten ohne Eintrag oben — sichtbar hinten rechts statt + * lautlos im Nichts. + */ +const FALLBACK = { side: SeatSide.RIGHT, x: 1.30, z: -4.75 } -/** Kopfende gegenüber: hinter der Tischkante, vor der Glasfront. */ -const HEAD_Z = -8.15 +/** Kopfende gegenüber. Unverändert: die Position wirkt gut (§3.2). */ +const HEAD_Z = -6.75 export const MEETING_SEATS: MeetingSeat[] = [ - ...AGENT_WORKSPACES.map((agent, i) => { - const left = i % 2 === 0 - return { - id: agent.id, - name: agent.name, - role: agent.role, - path: agent.path, - side: left ? SeatSide.LEFT : SeatSide.RIGHT, - x: left ? -SEAT_X : SEAT_X, - // Die Plätze rücken paarweise nach hinten: links und rechts versetzt, - // damit über die Tischmitte hinweg niemand hinter einem Kopf verschwindet. - z: SEAT_Z[Math.floor(i / 2)] - (left ? 0 : 0.9), - } - }), + ...AGENT_WORKSPACES.map(agent => ({ + id: agent.id, + name: agent.name, + role: agent.role, + path: agent.path, + ...(PLAETZE[agent.id] ?? FALLBACK), + })), { id: AGENT_SUPERVISOR.id, name: AGENT_SUPERVISOR.name,