Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55274488d1 | ||
|
|
403161283c | ||
|
|
9e825c04bf | ||
|
|
4349641ee9 |
No files matched your search
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Erzeugt die Agentenporträts aus den hochauflösenden Originalen.
|
||||||
|
*
|
||||||
|
* Die Porträts im Repository stammten bis Runde 6 aus einem HTML-Konzept und
|
||||||
|
* lagen dort nur als kleine, liegende Zuschnitte vor — Ferdi etwa 215 × 160
|
||||||
|
* Bildpunkte bei 6 KB. Im 3D-Meetingraum sind sie damit sichtbar unscharf, und
|
||||||
|
* hochskalieren hilft nicht.
|
||||||
|
*
|
||||||
|
* Die Originale liegen in der Präsentation `PropertyOn_Engeli_v5.pptx` als
|
||||||
|
* quadratische Bilder mit 1200 × 1200 Bildpunkten. Sie zeigen dieselben fünf
|
||||||
|
* Personen — Kleidung, Frisur und Ausschnitt stimmen mit den bisherigen
|
||||||
|
* Zuschnitten überein, es werden also keine Gesichter getauscht.
|
||||||
|
*
|
||||||
|
* Das Skript beschneidet den Rand (die Bilder tragen oben und an den Seiten
|
||||||
|
* Reste des Folienrahmens), skaliert auf eine für den Browser sinnvolle Grösse
|
||||||
|
* und schreibt sie nach `src/assets/team/`.
|
||||||
|
*
|
||||||
|
* Einmalig auszuführen; die Ergebnisse liegen als Assets im Repository:
|
||||||
|
*
|
||||||
|
* node scripts/build-agent-portraits.mjs
|
||||||
|
*
|
||||||
|
* Es braucht keine Bildbibliothek — die Verarbeitung läuft 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 { inflateRawSync } from 'node:zlib'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const ziel = resolve(here, '../src/assets/team')
|
||||||
|
|
||||||
|
/** Quellpräsentation. Nur zum Erzeugen nötig, nicht Teil des Repositorys. */
|
||||||
|
const QUELLE = process.env.PORTRAIT_SOURCE
|
||||||
|
?? 'C:/Users/beni_/Downloads/PropertyOn_Engeli_v5.pptx'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zuordnung Bild → Agent.
|
||||||
|
*
|
||||||
|
* Die Reihenfolge entspricht der Titelfolie der Präsentation: Ferdi, Bruno,
|
||||||
|
* Livia, Nora, Sina. Jede Zuordnung wurde einzeln gegen den bisherigen
|
||||||
|
* Zuschnitt geprüft — eine frühere Quelle trug dieselben Vornamen für eine
|
||||||
|
* andere Belegschaft, und ein vertauschtes Gesicht wäre schlimmer als ein
|
||||||
|
* unscharfes.
|
||||||
|
*/
|
||||||
|
const ZUORDNUNG = {
|
||||||
|
'image1.jpg': 'ferdi',
|
||||||
|
'image2.jpg': 'bruno',
|
||||||
|
'image3.jpg': 'livia',
|
||||||
|
'image4.jpg': 'nora',
|
||||||
|
'image5.jpg': 'sina',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kantenbeschnitt je Seite, als Anteil der Bildbreite. */
|
||||||
|
const BESCHNITT = 0.03
|
||||||
|
/** Zielgrösse. Reicht für jede Darstellung im Programm, bleibt klein im Bündel. */
|
||||||
|
const GROESSE = 512
|
||||||
|
|
||||||
|
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.')
|
||||||
|
if (!existsSync(QUELLE)) throw new Error(`Quelldatei fehlt: ${QUELLE}`)
|
||||||
|
|
||||||
|
/** Bilder aus dem ZIP-Container der Präsentation lesen, ohne Fremdbibliothek. */
|
||||||
|
function medienAusPptx(pfad, gesucht) {
|
||||||
|
const b = readFileSync(pfad)
|
||||||
|
const gefunden = {}
|
||||||
|
let i = 0
|
||||||
|
while (i < b.length - 4) {
|
||||||
|
if (b.readUInt32LE(i) !== 0x04034b50) { i++; continue }
|
||||||
|
const method = b.readUInt16LE(i + 8)
|
||||||
|
const csize = b.readUInt32LE(i + 18)
|
||||||
|
const nameLen = b.readUInt16LE(i + 26)
|
||||||
|
const extraLen = b.readUInt16LE(i + 28)
|
||||||
|
const name = b.slice(i + 30, i + 30 + nameLen).toString()
|
||||||
|
const start = i + 30 + nameLen + extraLen
|
||||||
|
const datei = name.split('/').pop()
|
||||||
|
if (name.startsWith('ppt/media/') && gesucht.includes(datei)) {
|
||||||
|
try {
|
||||||
|
gefunden[datei] = method === 8
|
||||||
|
? inflateRawSync(b.slice(start, start + csize))
|
||||||
|
: b.slice(start, start + csize)
|
||||||
|
} catch { /* beschädigter Eintrag — überspringen */ }
|
||||||
|
}
|
||||||
|
i = start + csize
|
||||||
|
}
|
||||||
|
return gefunden
|
||||||
|
}
|
||||||
|
|
||||||
|
const roh = medienAusPptx(QUELLE, Object.keys(ZUORDNUNG))
|
||||||
|
const fehlend = Object.keys(ZUORDNUNG).filter(f => !roh[f])
|
||||||
|
if (fehlend.length) throw new Error(`Nicht gefunden: ${fehlend.join(', ')}`)
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ executablePath: CHROME })
|
||||||
|
const page = await browser.newPage()
|
||||||
|
|
||||||
|
for (const [datei, agent] of Object.entries(ZUORDNUNG)) {
|
||||||
|
const dataUri = `data:image/jpeg;base64,${roh[datei].toString('base64')}`
|
||||||
|
const ergebnis = await page.evaluate(async ({ src, beschnitt, groesse }) => {
|
||||||
|
const img = await new Promise((ok, err) => {
|
||||||
|
const el = new Image()
|
||||||
|
el.onload = () => ok(el)
|
||||||
|
el.onerror = err
|
||||||
|
el.src = src
|
||||||
|
})
|
||||||
|
const rand = Math.round(Math.min(img.width, img.height) * beschnitt)
|
||||||
|
const sw = img.width - 2 * rand
|
||||||
|
const sh = img.height - 2 * rand
|
||||||
|
const c = document.createElement('canvas')
|
||||||
|
c.width = groesse
|
||||||
|
c.height = groesse
|
||||||
|
const g = c.getContext('2d')
|
||||||
|
g.imageSmoothingEnabled = true
|
||||||
|
g.imageSmoothingQuality = 'high'
|
||||||
|
g.drawImage(img, rand, rand, sw, sh, 0, 0, groesse, groesse)
|
||||||
|
return { dataUri: c.toDataURL('image/jpeg', 0.86), quelle: `${img.width}x${img.height}` }
|
||||||
|
}, { src: dataUri, beschnitt: BESCHNITT, groesse: GROESSE })
|
||||||
|
|
||||||
|
const aus = resolve(ziel, `${agent}.jpg`)
|
||||||
|
writeFileSync(aus, Buffer.from(ergebnis.dataUri.split(',')[1], 'base64'))
|
||||||
|
const kb = (readFileSync(aus).length / 1024).toFixed(0)
|
||||||
|
console.log(`${agent.padEnd(7)} ${ergebnis.quelle} → ${GROESSE}x${GROESSE}, ${kb} KB`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
/**
|
||||||
|
* 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 eine schmale Zone ü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
|
||||||
|
/**
|
||||||
|
* Massstab der Fortsetzung (Runde 7, §1).
|
||||||
|
*
|
||||||
|
* Nicht frei gewählt, sondern aus den Fotos gemessen: das Münster steht in
|
||||||
|
* basel-front 106 Pixel über der Wasserlinie, in basel-left 130. Damit
|
||||||
|
* dieselben Häuser in beiden Bildhälften gleich gross erscheinen, muss die
|
||||||
|
* Fortsetzung mit 1.3 × 106/130 laufen. Vorher stand hier 2.45 — daher der
|
||||||
|
* Massstabssprung an der Nahtstelle, bei dem die linken Häuser doppelt so hoch
|
||||||
|
* wirkten wie die vorderen.
|
||||||
|
*/
|
||||||
|
const LEFT_SCALE = 1.06
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 Mittelmotiv und Fortsetzung.
|
||||||
|
*
|
||||||
|
* Bewusst schmal (Runde 7, §1). Eine breite Überblendung mischt zwei
|
||||||
|
* verschiedene Häuserzeilen ineinander: beide bleiben halbdurchsichtig sichtbar
|
||||||
|
* und es entstehen «Geisterhäuser», die vor und über anderen Gebäuden stehen.
|
||||||
|
* Ein Überblenden funktioniert nur bei deckungsgleichem Inhalt — den gibt es
|
||||||
|
* hier nicht — schon 64 Pixel liessen ein blasses Dach über der echten Zeile
|
||||||
|
* stehen. Praktisch hart getrennt liest sich die Stelle als Übergang zwischen
|
||||||
|
* zwei Häuserblöcken am Fluss, und jedes Gebäude bleibt eindeutig.
|
||||||
|
*/
|
||||||
|
const FEATHER = 6
|
||||||
|
/** Überblendbreite in Himmel und Wasser — dort ist das Bild strukturlos. */
|
||||||
|
const FEATHER_WEICH = 300
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Höhenband der Häuserzeile im fertigen Bild, in Pixeln.
|
||||||
|
*
|
||||||
|
* Innerhalb dieses Bands wird schmal überblendet und nachgeschärft, ausserhalb
|
||||||
|
* breit überblendet und nicht geschärft. Die Grenzen folgen aus der Wasserlinie
|
||||||
|
* bei 400 und den Dachfirsten darüber.
|
||||||
|
*
|
||||||
|
* Die obere Grenze liegt über **allen** Dächern beider Aufnahmen, auch über den
|
||||||
|
* Dachaufbauten. Lag sie tiefer, schnitt sie die Häuserzeile des Mittelmotivs
|
||||||
|
* waagrecht durch, und deren Gauben blieben als Reihe schwebender Klötzchen im
|
||||||
|
* Himmel stehen.
|
||||||
|
*/
|
||||||
|
const BAND = { oben: 220, obenEnde: 238, unten: 398, untenEnde: 420 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nachschärfung (Unschärfemaske) gegen die Weichheit des Hochskalierens.
|
||||||
|
*
|
||||||
|
* Die Fortsetzung stammt aus einem kleinen Panoramafoto und wird auf ein
|
||||||
|
* Mehrfaches vergrössert; ohne Nachschärfung verlieren die Dachkanten ihre
|
||||||
|
* Kontur. `staerke` ist der Anteil der zurückgegebenen Kantenzeichnung.
|
||||||
|
*/
|
||||||
|
const SCHAERFE = { staerke: 0.85, radius: 1.1 }
|
||||||
|
|
||||||
|
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 lW = Math.round(cfg.LEFT_CROP.sw * cfg.LEFT_SCALE)
|
||||||
|
|
||||||
|
// Die beiden Bilder überlappen sich geometrisch über die volle weiche Breite.
|
||||||
|
// Nur so lässt sich in Himmel und Wasser breit überblenden — ausserhalb der
|
||||||
|
// Überlappung gäbe es nichts, worauf übergeblendet werden könnte, und es
|
||||||
|
// entstünde ein durchsichtiger und damit schwarzer Streifen.
|
||||||
|
const UEBER = cfg.FEATHER_WEICH
|
||||||
|
const W = lW + fW - UEBER
|
||||||
|
const H = cfg.H
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Quelle als Schicht, an der Wasserlinie ausgerichtet.
|
||||||
|
*
|
||||||
|
* Bewusst **ohne** senkrechtes Strecken über den Bildrand hinaus. Ein
|
||||||
|
* gestreckter Streifen Wasser zerfällt in Flecken und Striche. Was oben und
|
||||||
|
* unten fehlt, füllt weiter unten der Zeilenabgleich sauber auf.
|
||||||
|
*/
|
||||||
|
function schicht(img, crop, scale, waterFraction) {
|
||||||
|
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.drawImage(img, sx, 0, sw, img.height, 0, y, w, h)
|
||||||
|
return { canvas: c, w }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helligkeit der Fortsetzung **zeilenweise** an das Mittelmotiv angleichen
|
||||||
|
* (Runde 7, §1).
|
||||||
|
*
|
||||||
|
* Ein einziger Faktor für das ganze Bild reicht nicht: die beiden Aufnahmen
|
||||||
|
* unterscheiden sich im Himmel anders als in der Häuserzeile und im Wasser.
|
||||||
|
* Der übrig bleibende Unterschied stand als waagrechte Tonkante quer durch den
|
||||||
|
* Himmel. Verglichen wird nur dort, wo beide Bilder tatsächlich übereinander
|
||||||
|
* liegen; jede Zeile bekommt ihren eigenen Faktor.
|
||||||
|
*/
|
||||||
|
function tonAngleichen(fort, vorne, abX) {
|
||||||
|
const vonX = abX + Math.round(UEBER * 0.2)
|
||||||
|
const bisX = abX + Math.round(UEBER * 0.8)
|
||||||
|
const fb = fort.canvas.getContext('2d').getImageData(0, 0, fort.w, H)
|
||||||
|
const vb = vorne.canvas.getContext('2d').getImageData(0, 0, vorne.w, H)
|
||||||
|
/*
|
||||||
|
* Ein gemeinsamer Faktor für alle drei Kanäle, kein eigener je Kanal.
|
||||||
|
* Kanalweise verglichen werden Inhalte, die einander nicht entsprechen —
|
||||||
|
* an derselben Stelle stehen im einen Bild Bäume, im anderen Hausfassaden.
|
||||||
|
* Die Verhältnisse laufen dann auseinander und färben die Fortsetzung.
|
||||||
|
*/
|
||||||
|
let letzterFaktor = 1
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
let sf = 0, sv = 0, n = 0
|
||||||
|
for (let x = vonX; x < bisX; x++) {
|
||||||
|
const i = (y * fort.w + (x - abX)) * 4
|
||||||
|
const j = (y * vorne.w + x) * 4
|
||||||
|
if (fb.data[i + 3] < 250 || vb.data[j + 3] < 250) continue
|
||||||
|
sf += 0.2126 * fb.data[i] + 0.7152 * fb.data[i + 1] + 0.0722 * fb.data[i + 2]
|
||||||
|
sv += 0.2126 * vb.data[j] + 0.7152 * vb.data[j + 1] + 0.0722 * vb.data[j + 2]
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
// Zeilen ohne Überlappung übernehmen den Faktor der letzten vergleichbaren.
|
||||||
|
if (n >= 20 && sf >= 1) letzterFaktor = Math.max(0.75, Math.min(1.35, sv / sf))
|
||||||
|
for (let x = 0; x < fort.w; x++) {
|
||||||
|
const i = (y * fort.w + x) * 4
|
||||||
|
for (let c = 0; c < 3; c++) {
|
||||||
|
const wert = fb.data[i + c] * letzterFaktor
|
||||||
|
fb.data[i + c] = wert > 255 ? 255 : wert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fort.canvas.getContext('2d').putImageData(fb, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cv = document.createElement('canvas')
|
||||||
|
cv.width = W
|
||||||
|
cv.height = H
|
||||||
|
const g = cv.getContext('2d')
|
||||||
|
g.imageSmoothingEnabled = true
|
||||||
|
g.imageSmoothingQuality = 'high'
|
||||||
|
|
||||||
|
// 1) Beide Schichten aufbauen und die Fortsetzung zeilenweise im Ton
|
||||||
|
// angleichen, bevor irgendetwas zusammengesetzt wird.
|
||||||
|
const fort = schicht(lImg, cfg.LEFT_CROP, cfg.LEFT_SCALE, cfg.LEFT_WATER)
|
||||||
|
const vorne = schicht(fImg, null, cfg.FRONT_SCALE, cfg.FRONT_WATER)
|
||||||
|
tonAngleichen(fort, vorne, W - fort.w)
|
||||||
|
|
||||||
|
// 2) Fortsetzung rechts, darüber das Mittelmotiv mit höhenabhängig weicher
|
||||||
|
// rechter Flanke. Der Zylinder beginnt beim Mittelmotiv und läuft nach
|
||||||
|
// links in den Raum weiter; die Bildfolge muss dieser Richtung folgen.
|
||||||
|
/*
|
||||||
|
* Die Fortsetzung trägt **nur** die Häuserzeile bei (Runde 7, §1).
|
||||||
|
*
|
||||||
|
* Darüber und darunter wird sie ausgeblendet. Ihr Himmel ist milchig-weiss,
|
||||||
|
* der des Mittelmotivs kräftig blau; nebeneinander stand er als heller Block
|
||||||
|
* im Bild, und eine Helligkeitsangleichung holt die fehlende Farbsättigung
|
||||||
|
* nicht zurück. Ihr Wasser wiederum endet früh. Beides deckt weiter unten der
|
||||||
|
* Zeilenabgleich sauber ab — er verlängert Himmel und Wasser des
|
||||||
|
* Mittelmotivs über die volle Breite.
|
||||||
|
*/
|
||||||
|
const fk = fort.canvas.getContext('2d')
|
||||||
|
const fbild = fk.getImageData(0, 0, fort.w, H)
|
||||||
|
const fd = fbild.data
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Oben: der Himmel wird **spaltenweise entlang der echten Dachkante**
|
||||||
|
* entfernt, nicht mit einer waagrechten Linie abgeschnitten. Eine gerade
|
||||||
|
* Linie liegt entweder über den Dächern — dann bleibt ein heller Streifen
|
||||||
|
* Fremdhimmel stehen — oder sie schneidet mitten durch die Dächer.
|
||||||
|
*
|
||||||
|
* Gesucht wird je Spalte die erste Zeile, die sich deutlich von der
|
||||||
|
* Himmelsfarbe ganz oben in derselben Spalte unterscheidet. Drei Zeilen in
|
||||||
|
* Folge müssen abweichen, damit eine Wolke die Kante nicht vortäuscht.
|
||||||
|
*/
|
||||||
|
const obenRand = new Array(fort.w).fill(0)
|
||||||
|
for (let x = 0; x < fort.w; x++) {
|
||||||
|
let y0 = 0
|
||||||
|
while (y0 < H && fd[(y0 * fort.w + x) * 4 + 3] < 250) y0++
|
||||||
|
if (y0 >= H) { obenRand[x] = H; continue }
|
||||||
|
/*
|
||||||
|
* Verglichen wird mit der Zeile acht darüber, nicht mit der obersten Zeile
|
||||||
|
* der Spalte. Der Himmel verläuft von oben nach unten langsam von blau nach
|
||||||
|
* dunstig; gegen einen festen Bezugswert überschreitet allein dieser Verlauf
|
||||||
|
* irgendwann jede Schwelle, und die Kante läge zu hoch. Ein Dachrand
|
||||||
|
* dagegen ändert die Farbe auf wenigen Zeilen sprunghaft.
|
||||||
|
*/
|
||||||
|
let treffer = 0
|
||||||
|
let kante = H
|
||||||
|
for (let y = y0 + 8; y < H; y++) {
|
||||||
|
const i = (y * fort.w + x) * 4
|
||||||
|
const o = ((y - 8) * fort.w + x) * 4
|
||||||
|
const sprung = Math.abs(fd[i] - fd[o]) + Math.abs(fd[i + 1] - fd[o + 1]) + Math.abs(fd[i + 2] - fd[o + 2])
|
||||||
|
treffer = sprung > 60 ? treffer + 1 : 0
|
||||||
|
if (treffer >= 2) { kante = y - 9; break }
|
||||||
|
}
|
||||||
|
obenRand[x] = kante
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* Median statt Mittelwert über fünf Spalten. Ein Mittelwert zieht die Kante
|
||||||
|
* an jedem Dachabsatz zu weit nach oben, und darüber blieben kleine Fetzen
|
||||||
|
* fremden Himmels als Flecken stehen. Der Median folgt der Dachlinie und
|
||||||
|
* bügelt trotzdem einzelne Ausreisser aus.
|
||||||
|
*/
|
||||||
|
const geglaettet = obenRand.map((_, x) => {
|
||||||
|
const fenster = []
|
||||||
|
for (let k = -2; k <= 2; k++) if (obenRand[x + k] !== undefined) fenster.push(obenRand[x + k])
|
||||||
|
fenster.sort((a, b) => a - b)
|
||||||
|
return fenster[Math.floor(fenster.length / 2)]
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Himmelsfarbe je Spalte, gemessen an der obersten Bildzeile der Schicht. */
|
||||||
|
const himmel = []
|
||||||
|
for (let x = 0; x < fort.w; x++) {
|
||||||
|
let y0 = 0
|
||||||
|
while (y0 < H && fd[(y0 * fort.w + x) * 4 + 3] < 250) y0++
|
||||||
|
const i = (y0 * fort.w + x) * 4
|
||||||
|
himmel.push(y0 < H ? [fd[i], fd[i + 1], fd[i + 2]] : [0, 0, 0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unten endet die Fortsetzung kurz unter der Wasserlinie; darunter übernimmt
|
||||||
|
// das Wasser des Mittelmotivs, das viel weiter hinunterreicht.
|
||||||
|
const untenAb = cfg.BAND.untenEnde + 10
|
||||||
|
const untenBis = cfg.BAND.untenEnde + 40
|
||||||
|
for (let x = 0; x < fort.w; x++) {
|
||||||
|
const kante = geglaettet[x]
|
||||||
|
const ref = himmel[x]
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
const i = (y * fort.w + x) * 4
|
||||||
|
let obenA = Math.max(0, Math.min(1, (y - (kante - 2)) / 5))
|
||||||
|
/*
|
||||||
|
* Sicherheitsnetz dicht unter der Kante: was dort noch Himmelsfarbe hat,
|
||||||
|
* ist Himmel und verschwindet ebenfalls. Sonst bleiben an Dachabsätzen
|
||||||
|
* einzelne helle Flecken zwischen den Häusern stehen.
|
||||||
|
*/
|
||||||
|
if (obenA > 0 && y < kante + 14) {
|
||||||
|
const abstand = Math.abs(fd[i] - ref[0]) + Math.abs(fd[i + 1] - ref[1]) + Math.abs(fd[i + 2] - ref[2])
|
||||||
|
if (abstand < 70) obenA = 0
|
||||||
|
}
|
||||||
|
const untenA = y < untenAb ? 1 : Math.max(0, 1 - (y - untenAb) / (untenBis - untenAb))
|
||||||
|
fd[i + 3] = Math.round(fd[i + 3] * obenA * untenA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fk.putImageData(fbild, 0, 0)
|
||||||
|
|
||||||
|
g.drawImage(fort.canvas, W - fort.w, 0)
|
||||||
|
const mid = document.createElement('canvas')
|
||||||
|
mid.width = vorne.w
|
||||||
|
mid.height = H
|
||||||
|
const mg = mid.getContext('2d')
|
||||||
|
mg.drawImage(vorne.canvas, 0, 0)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Überblendung mit höhenabhängiger Breite (Runde 7, §1).
|
||||||
|
*
|
||||||
|
* Über der Häuserzeile und unter der Wasserlinie ist das Bild strukturlos —
|
||||||
|
* dort wird über die volle Überlappung übergeblendet, und nur so verschwindet
|
||||||
|
* der Helligkeitssprung im Himmel, den die Angleichung sonst als senkrechte
|
||||||
|
* Kante stehen lässt. In der Häuserzeile selbst muss die Überblendung schmal
|
||||||
|
* sein: dort stehen zwei verschiedene Häuserreihen übereinander, und jede
|
||||||
|
* Breite mischt sie zu halbdurchsichtigen «Geisterhäusern».
|
||||||
|
*
|
||||||
|
* Beide Übergänge haben dieselbe Mitte, damit die Trennlinie über die ganze
|
||||||
|
* Bildhöhe an derselben Stelle liegt und nicht schräg verläuft.
|
||||||
|
*/
|
||||||
|
const mitte = vorne.w - UEBER / 2
|
||||||
|
const maske = document.createElement('canvas')
|
||||||
|
maske.width = vorne.w
|
||||||
|
maske.height = H
|
||||||
|
const kg = maske.getContext('2d')
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
const imBand =
|
||||||
|
y < cfg.BAND.oben ? 0
|
||||||
|
: y < cfg.BAND.obenEnde ? (y - cfg.BAND.oben) / (cfg.BAND.obenEnde - cfg.BAND.oben)
|
||||||
|
: y < cfg.BAND.unten ? 1
|
||||||
|
: y < cfg.BAND.untenEnde ? 1 - (y - cfg.BAND.unten) / (cfg.BAND.untenEnde - cfg.BAND.unten)
|
||||||
|
: 0
|
||||||
|
/*
|
||||||
|
* Ausserhalb der Häuserzeile reicht das Mittelmotiv bis an seinen eigenen
|
||||||
|
* rechten Rand: dort trägt die Fortsetzung nichts mehr bei, weil sie oben
|
||||||
|
* und unten ausgeblendet ist. Innerhalb der Zeile wandert die Trennung in
|
||||||
|
* die Mitte der Überlappung und wird praktisch hart.
|
||||||
|
*/
|
||||||
|
const x0 = (vorne.w - 4) + (mitte - (vorne.w - 4)) * imBand
|
||||||
|
const b0 = 8 + (cfg.FEATHER - 8) * imBand
|
||||||
|
|
||||||
|
const grad = kg.createLinearGradient(x0 - b0 / 2, 0, x0 + b0 / 2, 0)
|
||||||
|
grad.addColorStop(0, 'rgba(0,0,0,1)')
|
||||||
|
grad.addColorStop(1, 'rgba(0,0,0,0)')
|
||||||
|
kg.fillStyle = grad
|
||||||
|
kg.fillRect(0, y, vorne.w, 1)
|
||||||
|
}
|
||||||
|
// Die Maske entsteht zuerst vollständig auf einer eigenen Fläche. Zeilenweise
|
||||||
|
// direkt mit `destination-in` zu arbeiten ginge nicht: dieser Modus wirkt auf
|
||||||
|
// die ganze Fläche und löschte bei jeder Zeile alle übrigen.
|
||||||
|
mg.globalCompositeOperation = 'destination-in'
|
||||||
|
mg.drawImage(maske, 0, 0)
|
||||||
|
g.drawImage(mid, 0, 0)
|
||||||
|
|
||||||
|
const bild = g.getImageData(0, 0, W, H)
|
||||||
|
const d = bild.data
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 3) Freie Stellen zeilenweise auffüllen.
|
||||||
|
*
|
||||||
|
* Oben und unten reichen die Fotos nicht über die volle Höhe, und rechts endet
|
||||||
|
* die Fortsetzung früher als das Mittelmotiv. Diese Stellen sind Himmel oder
|
||||||
|
* Wasser: beides ist waagrecht nahezu einfarbig. Sie bekommen deshalb die
|
||||||
|
* Durchschnittsfarbe ihrer eigenen Zeile, zum Rand hin weich an die dort
|
||||||
|
* tatsächlich vorhandenen Bildpunkte angeglichen. Das ergibt eine ruhige
|
||||||
|
* Fortsetzung ohne Striche, Flecken oder Wiederholungen.
|
||||||
|
*/
|
||||||
|
// Erst je Zeile die Durchschnittsfarbe der echten Bildpunkte bestimmen.
|
||||||
|
const mittelwerte = new Array(H).fill(null)
|
||||||
|
const luecken = new Array(H).fill(0)
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
const zeile = y * W * 4
|
||||||
|
let sr = 0, sg = 0, sb = 0, n = 0
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
if (d[zeile + x * 4 + 3] > 250) { sr += d[zeile + x * 4]; sg += d[zeile + x * 4 + 1]; sb += d[zeile + x * 4 + 2]; n++ }
|
||||||
|
}
|
||||||
|
if (n > 0) mittelwerte[y] = [sr / n, sg / n, sb / n]
|
||||||
|
luecken[y] = W - n
|
||||||
|
}
|
||||||
|
// Zeilen ganz ohne Bildpunkte — ganz oben und ganz unten — übernehmen die
|
||||||
|
// Farbe der nächsten Zeile, die welche hat. Ein fester Ersatzwert stünde dort
|
||||||
|
// sonst als grauer Streifen quer über dem Himmel.
|
||||||
|
for (let y = 0; y < H; y++) if (!mittelwerte[y]) {
|
||||||
|
for (let k = 1; k < H; k++) {
|
||||||
|
if (y + k < H && mittelwerte[y + k]) { mittelwerte[y] = mittelwerte[y + k]; break }
|
||||||
|
if (y - k >= 0 && mittelwerte[y - k]) { mittelwerte[y] = mittelwerte[y - k]; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
if (luecken[y] === 0) continue
|
||||||
|
const zeile = y * W * 4
|
||||||
|
const mittel = mittelwerte[y]
|
||||||
|
|
||||||
|
// Farbe des nächstgelegenen echten Bildpunkts links und rechts merken
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
const i = zeile + x * 4
|
||||||
|
if (d[i + 3] > 250) continue
|
||||||
|
/*
|
||||||
|
* Nur die unmittelbare Nachbarschaft zählt. Wurde weiter gesucht, trug
|
||||||
|
* die Füllung die Farbe des nächsten Dachs waagrecht in den Himmel
|
||||||
|
* hinein — als lange helle Schlieren über der Häuserzeile (Runde 7, §1).
|
||||||
|
* Ein paar Bildpunkte reichen, um die Kante weich zu machen; alles
|
||||||
|
* weitere bekommt die ruhige Durchschnittsfarbe der Zeile.
|
||||||
|
*/
|
||||||
|
let nachbar = null
|
||||||
|
for (let dx = 1; dx <= 12 && !nachbar; dx++) {
|
||||||
|
for (const xx of [x - dx, x + dx]) {
|
||||||
|
if (xx >= 0 && xx < W && d[zeile + xx * 4 + 3] > 250) {
|
||||||
|
nachbar = { c: [d[zeile + xx * 4], d[zeile + xx * 4 + 1], d[zeile + xx * 4 + 2]], dist: dx }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* Der Nachbar zählt nur, wenn er der Zeile ähnlich sieht. Ein Dach oder
|
||||||
|
* ein Kamin am Rand der Lücke färbte die Füllung sonst ein und zog seine
|
||||||
|
* Farbe als helle Schliere waagrecht in den Himmel.
|
||||||
|
*/
|
||||||
|
const passend = nachbar && Math.abs(nachbar.c[0] - mittel[0]) + Math.abs(nachbar.c[1] - mittel[1]) + Math.abs(nachbar.c[2] - mittel[2]) < 90
|
||||||
|
const t = passend ? Math.min(1, nachbar.dist / 10) : 1
|
||||||
|
const quelle = passend ? nachbar.c : mittel
|
||||||
|
for (let c = 0; c < 3; c++) d[i + c] = quelle[c] * (1 - t) + mittel[c] * t
|
||||||
|
d[i + 3] = 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.putImageData(bild, 0, 0)
|
||||||
|
|
||||||
|
// 4) Nachschärfen (Unschärfemaske): das Bild minus seiner weichgezeichneten
|
||||||
|
// Fassung ergibt die Kantenzeichnung; sie wird verstärkt zurückgegeben.
|
||||||
|
// Das holt die Dachkanten der hochskalierten Häuserzeile zurück (§1).
|
||||||
|
const weich = document.createElement('canvas')
|
||||||
|
weich.width = W
|
||||||
|
weich.height = H
|
||||||
|
const wg = weich.getContext('2d')
|
||||||
|
wg.filter = `blur(${cfg.SCHAERFE.radius}px)`
|
||||||
|
wg.drawImage(cv, 0, 0)
|
||||||
|
|
||||||
|
const oBild = g.getImageData(0, 0, W, H)
|
||||||
|
const wBild = wg.getImageData(0, 0, W, H)
|
||||||
|
const o = oBild.data
|
||||||
|
const w = wBild.data
|
||||||
|
// Nur im Strukturband schärfen. Im Himmel brächte Schärfen nichts, im Wasser
|
||||||
|
// verstärkte es jede Unruhe der aufgefüllten Fläche.
|
||||||
|
for (let y = cfg.BAND.oben; y < cfg.BAND.untenEnde && y < H; y++) {
|
||||||
|
const rand = Math.min(y - cfg.BAND.oben, cfg.BAND.untenEnde - y, 24) / 24
|
||||||
|
const k = cfg.SCHAERFE.staerke * Math.max(0, rand)
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
const i = (y * W + x) * 4
|
||||||
|
for (let c = 0; c < 3; c++) {
|
||||||
|
const wert = o[i + c] + k * (o[i + c] - w[i + c])
|
||||||
|
o[i + c] = wert < 0 ? 0 : wert > 255 ? 255 : wert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.putImageData(oBild, 0, 0)
|
||||||
|
|
||||||
|
return { dataUri: cv.toDataURL('image/jpeg', 0.9), W, H }
|
||||||
|
}, {
|
||||||
|
front: asDataUri(SRC_FRONT),
|
||||||
|
left: asDataUri(SRC_LEFT),
|
||||||
|
H, WATER_Y, FRONT_WATER, LEFT_WATER, FRONT_SCALE, LEFT_SCALE, FEATHER, FEATHER_WEICH, BAND, LEFT_CROP, SCHAERFE,
|
||||||
|
})
|
||||||
|
|
||||||
|
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`)
|
||||||
|
After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 32 KiB |
@@ -37,6 +37,9 @@ const ROLE_LABELS: Record<string, string> = {
|
|||||||
*/
|
*/
|
||||||
export function GlobalNavMenu() {
|
export function GlobalNavMenu() {
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(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 { currentUser, logout } = useSessionStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const showToast = useToastStore(s => s.showToast)
|
const showToast = useToastStore(s => s.showToast)
|
||||||
@@ -61,7 +64,12 @@ export function GlobalNavMenu() {
|
|||||||
<Tooltip title="Menü">
|
<Tooltip title="Menü">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={e => 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-label="Menü öffnen"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
@@ -75,12 +83,22 @@ export function GlobalNavMenu() {
|
|||||||
open={open}
|
open={open}
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
onClose={close}
|
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' }}
|
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||||
|
marginThreshold={0}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
paper: {
|
paper: {
|
||||||
sx: {
|
sx: {
|
||||||
mt: 1,
|
|
||||||
width: 320,
|
width: 320,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
boxShadow: '0 18px 50px rgba(15,25,35,0.22)',
|
boxShadow: '0 18px 50px rgba(15,25,35,0.22)',
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import { MEETING_SEATS } from './meetingRoomSeats'
|
|||||||
import { AGENT_PHOTOS } from '../team/agentPhotos'
|
import { AGENT_PHOTOS } from '../team/agentPhotos'
|
||||||
import { useAgentWorkload } from '../../hooks/useAgentWorkload'
|
import { useAgentWorkload } from '../../hooks/useAgentWorkload'
|
||||||
import { WorkloadTone } from '../../domain/agentWorkload'
|
import { WorkloadTone } from '../../domain/agentWorkload'
|
||||||
import { DS_ACCENT } from '../../lib/ds'
|
import { DS_ACCENT, DS_NEUTRAL } from '../../lib/ds'
|
||||||
import { DS_NEUTRAL, DS_TEXT } from '../../lib/ds'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Farben der Auslastung auf dunklem Grund. Hellere Stufen als die
|
* Farben der Auslastung auf dunklem Grund. Hellere Stufen als die
|
||||||
@@ -328,15 +327,6 @@ export function MeetingRoom3D() {
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', right: 12, bottom: 8,
|
|
||||||
fontSize: '0.625rem', letterSpacing: 0.4,
|
|
||||||
color: DS_TEXT.inverted, opacity: 0.45, pointerEvents: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Kleinbasel · Blick über den Rhein nach Grossbasel
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -32,10 +32,6 @@ export const ROOM_COLORS = {
|
|||||||
tableTop: 0x8f7350,
|
tableTop: 0x8f7350,
|
||||||
tableEdge: 0x6f5637,
|
tableEdge: 0x6f5637,
|
||||||
tableBase: 0x35322f,
|
tableBase: 0x35322f,
|
||||||
/** Stuhlschalen. */
|
|
||||||
chair: 0x2f343c,
|
|
||||||
/** Anzüge der Agenten, leicht variiert, damit die Reihe nicht uniform wirkt. */
|
|
||||||
suits: [0x2c3444, 0x36302c, 0x2a3a3c, 0x3a2f36, 0x24303a, 0x1f2836],
|
|
||||||
/** Ring um das Porträt — hebt den Kopf vom Hintergrund ab. */
|
/** Ring um das Porträt — hebt den Kopf vom Hintergrund ab. */
|
||||||
headRing: 0xffffff,
|
headRing: 0xffffff,
|
||||||
/** Ring bei Hover: Gold aus dem Designsystem. */
|
/** Ring bei Hover: Gold aus dem Designsystem. */
|
||||||
@@ -51,18 +47,24 @@ export const ROOM_COLORS = {
|
|||||||
export const ROOM = {
|
export const ROOM = {
|
||||||
width: 7.4,
|
width: 7.4,
|
||||||
height: 3.05,
|
height: 3.05,
|
||||||
depth: 10.6,
|
depth: 9.2,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** Kamera: Augenhöhe einer sitzenden Person am Kopfende (§2). */
|
/** Kamera: Augenhöhe einer sitzenden Person am Kopfende (§2). */
|
||||||
export const CAMERA = {
|
export const CAMERA = {
|
||||||
/** Sitzende Augenhöhe über dem Boden. */
|
/** Sitzende Augenhöhe über dem Boden. */
|
||||||
eyeHeight: 1.38,
|
eyeHeight: 1.19,
|
||||||
/** Knapp hinter der vorderen Tischkante. */
|
/** Knapp hinter der vorderen Tischkante. */
|
||||||
z: 1.9,
|
z: 1.9,
|
||||||
fov: 48,
|
fov: 48,
|
||||||
|
/**
|
||||||
|
* Obergrenze des waagrechten Bildwinkels in Grad. Greift nur bei sehr breiten
|
||||||
|
* und gleichzeitig flachen Fenstern; bei üblichen Seitenverhältnissen liegt
|
||||||
|
* der waagrechte Winkel darunter und die Ansicht bleibt unverändert.
|
||||||
|
*/
|
||||||
|
maxHFov: 84,
|
||||||
/** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */
|
/** 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
|
* Maximaler Kameraversatz der Parallaxe in Metern. Bewusst winzig: die
|
||||||
* Perspektive ist verbindlich, die Bewegung darf sie nur beleben (§20).
|
* Perspektive ist verbindlich, die Bewegung darf sie nur beleben (§20).
|
||||||
@@ -72,25 +74,29 @@ export const CAMERA = {
|
|||||||
|
|
||||||
/** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */
|
/** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */
|
||||||
export const TABLE = {
|
export const TABLE = {
|
||||||
width: 1.62,
|
width: 1.70,
|
||||||
/** Höhe der Plattenoberseite über dem Boden. */
|
/** Höhe der Plattenoberseite über dem Boden. */
|
||||||
top: 0.745,
|
top: 0.745,
|
||||||
thickness: 0.06,
|
thickness: 0.06,
|
||||||
/** Vordere Kante, knapp vor der Kamera. */
|
/** Vordere Kante, knapp vor der Kamera. */
|
||||||
zNear: 0.15,
|
zNear: 0.15,
|
||||||
/** Hintere Kante, vor dem Platz von Thomas. */
|
/** Hintere Kante, vor dem Platz von Thomas. */
|
||||||
zFar: -7.35,
|
zFar: -5.75,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** Sitzende Personen: Kopfhöhe und Grössen. */
|
/** Sitzende Personen: Höhe der Porträtkreise. */
|
||||||
export const SEATED = {
|
export const SEATED = {
|
||||||
/** Kopfmitte über dem Boden — deutlich über der Tischplatte. */
|
/**
|
||||||
headY: 1.30,
|
* Luft zwischen Tischplatte und Unterkante des Porträtkreises (Runde 7, §2).
|
||||||
headRadius: 0.155,
|
*
|
||||||
/** Torso vom Stuhl bis zum Hals. */
|
* Daraus ergibt sich die Kopfhöhe: `TABLE.top + radius + headGap`. Die Kreise
|
||||||
torsoTop: 1.12,
|
* schweben damit nur knapp über der Tischkante statt weit darüber. Vorher
|
||||||
torsoBottom: 0.42,
|
* hingen sie fest auf 1.52 m — bei den jetzt deutlich grösseren Kreisen sähe
|
||||||
torsoRadiusTop: 0.19,
|
* das aus, als schwebten Köpfe frei im Raum.
|
||||||
torsoRadiusBottom: 0.25,
|
*
|
||||||
shoulderWidth: 0.46,
|
* Getrennt gehalten werden die Plätze in der Tiefe nicht über die Höhe,
|
||||||
|
* sondern seitlich: die Werte in `meetingRoomSeats` sind so gewählt, dass sich
|
||||||
|
* im Bild keine zwei Kreise überschneiden.
|
||||||
|
*/
|
||||||
|
headGap: 0.05,
|
||||||
} as const
|
} as const
|
||||||
@@ -2,8 +2,7 @@ import * as THREE from 'three'
|
|||||||
import { ROOM, ROOM_COLORS, CAMERA, TABLE, SEATED } from './meetingRoomPalette'
|
import { ROOM, ROOM_COLORS, CAMERA, TABLE, SEATED } from './meetingRoomPalette'
|
||||||
import { SeatSide } from './meetingRoomSeats'
|
import { SeatSide } from './meetingRoomSeats'
|
||||||
import type { MeetingSeat } from './meetingRoomSeats'
|
import type { MeetingSeat } from './meetingRoomSeats'
|
||||||
import baselFront from '../../assets/staff/basel-front.jpg'
|
import baselPanorama from '../../assets/staff/basel-panorama.jpg'
|
||||||
import baselLeft from '../../assets/staff/basel-left.jpg'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aufbau des virtuellen Meetingraums (Runde 6, §2–§6).
|
* Aufbau des virtuellen Meetingraums (Runde 6, §2–§6).
|
||||||
@@ -124,51 +123,81 @@ function addRoomShell(scene: THREE.Scene) {
|
|||||||
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.wallSkirting }),
|
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.wallSkirting }),
|
||||||
)
|
)
|
||||||
skirting.rotation.y = -Math.PI / 2
|
skirting.rotation.y = -Math.PI / 2
|
||||||
skirting.position.set(halfW - 0.01, 0.04, -depth / 2)
|
// Eine Idee vor dem Paneel: auf gleicher Höhe streiten sich die beiden
|
||||||
|
// Flächen um dieselben Bildpunkte, und die Kante franst sichtbar aus.
|
||||||
|
skirting.position.set(halfW - 0.015, 0.04, -depth / 2)
|
||||||
scene.add(skirting)
|
scene.add(skirting)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Basler Fernkulisse (§4).
|
* Basler Fernkulisse (Runde 6, §2).
|
||||||
*
|
*
|
||||||
* Zwei grosse Flächen weit aussen vor den Glasfronten, texturiert mit den beiden
|
* **Eine** gekrümmte Fläche mit **einem** Panoramabild — nicht mehr zwei
|
||||||
* Referenzfotos aus dem Auftrag. Sie stehen so weit weg, dass die Perspektive
|
* Flächen mit zwei Fotos. Genau deren Stossstelle war auf der linken
|
||||||
* sie als Ferne liest und die Fensterpfosten davor sauber schneiden.
|
* 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
|
* Das Panorama entsteht einmalig aus den beiden Referenzfotos des Auftrags;
|
||||||
* Blick nach vorne und der Blick nach links zusammengehören, liegt die
|
* siehe `scripts/build-basel-panorama.mjs`. Das Motiv vom Tischende bleibt darin
|
||||||
* Wasserlinie beider Kulissen auf derselben Welthöhe — sonst wirkte es wie zwei
|
* unverändert und wird nach links fortgeführt.
|
||||||
* zufällig zusammengesetzte Bilder.
|
*
|
||||||
|
* 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
|
||||||
|
/**
|
||||||
|
* Masse der Panoramadatei. Ändert sich das Bild, gehören die Zahlen
|
||||||
|
* nachgeführt — sonst zieht der Zylinder es in die Länge oder staucht es.
|
||||||
|
*/
|
||||||
|
const panorama = { breite: 1028, hoehe: 660 }
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Der Wert hält die Bildpunkte je Grad konstant: das Panorama ist seit
|
||||||
|
* Runde 7 schmaler, weil die Fortsetzung nicht mehr überskaliert wird, und
|
||||||
|
* der Bogen schrumpft im selben Verhältnis mit. Die Frontscheibe zeigt
|
||||||
|
* dadurch unverändert denselben Ausschnitt in derselben Grösse (§1: der
|
||||||
|
* Gesamteindruck bleibt, nur die Unsauberkeiten verschwinden).
|
||||||
|
*/
|
||||||
|
const arc = 1.514
|
||||||
|
/** Höhe passend zum Seitenverhältnis des Panoramas. */
|
||||||
|
const height = (radius * arc) / (panorama.breite / panorama.hoehe)
|
||||||
|
/** Wasserlinie im Panorama, als Anteil von oben. */
|
||||||
|
const waterFraction = 400 / panorama.hoehe
|
||||||
/** Höhe der Rheinoberfläche relativ zum Bürofussboden — wir sind im Hochhaus. */
|
/** Höhe der Rheinoberfläche relativ zum Bürofussboden — wir sind im Hochhaus. */
|
||||||
const waterY = -7.5
|
const waterY = -7.5
|
||||||
|
|
||||||
function backdrop(url: string, waterFraction: number, w: number, h: number) {
|
const tex = loader.load(baselPanorama)
|
||||||
const tex = loader.load(url)
|
tex.colorSpace = THREE.SRGBColorSpace
|
||||||
tex.colorSpace = THREE.SRGBColorSpace
|
tex.anisotropy = maxAnisotropy
|
||||||
const mesh = new THREE.Mesh(
|
// Kein Wiederholen: der Bogen deckt genau die Bildbreite ab, und eine
|
||||||
new THREE.PlaneGeometry(w, h),
|
// Wiederholung wäre als Doppelung sichtbar (§2.2).
|
||||||
// Unbeleuchtet: das Foto trägt seine eigene Belichtung, jede Beleuchtung
|
tex.wrapS = THREE.ClampToEdgeWrapping
|
||||||
// würde es nur verfälschen.
|
tex.wrapT = THREE.ClampToEdgeWrapping
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vorne: Blick über den Rhein auf Altstadt und Münster. Wasserlinie bei ~60 %.
|
const mesh = new THREE.Mesh(
|
||||||
const front = backdrop(baselFront, 0.6, 70, 47)
|
new THREE.CylinderGeometry(radius, radius, height, 96, 1, true, 0, arc),
|
||||||
front.position.z = -58
|
// Unbeleuchtet und von innen sichtbar: das Foto trägt seine eigene
|
||||||
scene.add(front)
|
// Belichtung, jede Beleuchtung würde sie nur verfälschen.
|
||||||
|
new THREE.MeshBasicMaterial({ map: tex, side: THREE.BackSide, fog: false }),
|
||||||
// Links: Rheinverlauf mit Altstadtsilhouette. Panoramaformat, Wasserlinie ~64 %.
|
)
|
||||||
const left = backdrop(baselLeft, 0.64, 118, 38)
|
// Mittelpunkt so setzen, dass die Wasserlinie des Panoramas auf `waterY` liegt.
|
||||||
left.rotation.y = Math.PI / 2
|
mesh.position.y = waterY - height * (0.5 - waterFraction)
|
||||||
left.position.x = -56
|
/**
|
||||||
left.position.z = -26
|
* Drehung: geradeaus sichtbar ist die Bildstelle u = (π − Drehung) / Bogen.
|
||||||
scene.add(left)
|
* Der Abzug von 0.68 setzt damit u ≈ 0.45 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). */
|
/** Bodentiefe Glasfassade mit Pfosten — links und gegenüber (§3). */
|
||||||
@@ -264,122 +293,181 @@ function addTable(scene: THREE.Scene) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein sitzender Agent (§6).
|
* Nachschärfen nach dem Verkleinern (Unschärfemaske).
|
||||||
*
|
*
|
||||||
* Kopf ist eine Kreisfläche mit dem bestehenden Porträt als Textur — die
|
* Beim Herunterrechnen eines Fotos verschwimmen Kanten — Wimpern, Brillenrand,
|
||||||
* Kreisgeometrie beschneidet das quadratische Foto rund, genau wie die Avatare
|
* einzelne Haare. Das Bild minus seiner weichgezeichneten Fassung ergibt genau
|
||||||
* im übrigen Programm. Darunter ein Torso in Anzugfarbe und eine Stuhllehne.
|
* diese Kantenzeichnung; verstärkt zurückgegeben holt sie die Zeichnung wieder
|
||||||
|
* heran. Jedes Bildbearbeitungsprogramm macht das nach einer Verkleinerung.
|
||||||
*
|
*
|
||||||
* Der Kopf ist um `yaw` zum Tisch gedreht: die Agenten schauen ins Geschehen
|
* Die Stärke richtet sich danach, wie weit verkleinert wurde: wer kaum
|
||||||
* (§2), bleiben dabei aber lesbar. Voll zur Tischmitte gedreht sähe man von
|
* verkleinert, braucht kaum Schärfung, sonst wirkt das Ergebnis überzeichnet.
|
||||||
* einem frontalen Porträtfoto nur die Kante.
|
*/
|
||||||
|
function schaerfeNach(g: CanvasRenderingContext2D, flaeche: HTMLCanvasElement, ziel: number, kante: number) {
|
||||||
|
const staerke = Math.min(0.6, Math.max(0, (kante / ziel - 1) * 0.5))
|
||||||
|
if (staerke < 0.05) return
|
||||||
|
|
||||||
|
const weich = document.createElement('canvas')
|
||||||
|
weich.width = ziel
|
||||||
|
weich.height = ziel
|
||||||
|
const wg = weich.getContext('2d')
|
||||||
|
if (!wg) return
|
||||||
|
wg.filter = 'blur(0.8px)'
|
||||||
|
wg.drawImage(flaeche, 0, 0)
|
||||||
|
|
||||||
|
const scharf = g.getImageData(0, 0, ziel, ziel)
|
||||||
|
const unscharf = wg.getImageData(0, 0, ziel, ziel)
|
||||||
|
const a = scharf.data
|
||||||
|
const b = unscharf.data
|
||||||
|
for (let i = 0; i < a.length; i += 4) {
|
||||||
|
for (let c = 0; c < 3; c++) {
|
||||||
|
const wert = a[i + c] + staerke * (a[i + c] - b[i + c])
|
||||||
|
a[i + c] = wert < 0 ? 0 : wert > 255 ? 255 : wert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.putImageData(scharf, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Porträt laden und dabei auf seine Anzeigegrösse bringen (Runde 7, Nachtrag).
|
||||||
|
*
|
||||||
|
* Die Vorlagen haben 512 Bildpunkte, die Kreise erscheinen je nach Platz aber
|
||||||
|
* nur 170 bis 330 gross. Bei so starker Verkleinerung greift die Grafikkarte
|
||||||
|
* nicht auf die Vorlage zurück, sondern auf eine vorberechnete, halb oder
|
||||||
|
* viertel so grosse Zwischenfassung. Das Ergebnis sind weiche Gesichter,
|
||||||
|
* am deutlichsten bei den weiter hinten sitzenden Agenten.
|
||||||
|
*
|
||||||
|
* Wird die Textur vorab auf ihre Anzeigegrösse gerechnet, entfällt dieser
|
||||||
|
* Zwischenschritt. Verkleinert wird einmalig auf einer Zeichenfläche, und die
|
||||||
|
* rechnet dabei über alle Quellpunkte — deutlich sorgfältiger als die schnelle
|
||||||
|
* Zwischenfassung der Grafikkarte.
|
||||||
|
*
|
||||||
|
* Zugleich entsteht hier der mittige quadratische Ausschnitt. Ohne ihn zöge die
|
||||||
|
* Kreisfläche eine liegende Vorlage in die Breite.
|
||||||
|
*
|
||||||
|
* Geladen wird bewusst von Hand statt über `TextureLoader`: dessen Rückruf
|
||||||
|
* liefert das fertige Bild nicht zuverlässig, und ohne Bildmasse lässt sich
|
||||||
|
* weder zuschneiden noch massgerecht verkleinern. Die Zählung beim
|
||||||
|
* Ladeverwalter bleibt erhalten, damit die Szene nach dem letzten Bild wie
|
||||||
|
* bisher einmal neu zeichnet.
|
||||||
|
*/
|
||||||
|
function ladePortraet(url: string, seat: MeetingSeat, leinwandHoehe: number, maxAnisotropy: number) {
|
||||||
|
const tex = new THREE.Texture()
|
||||||
|
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 verwalter = THREE.DefaultLoadingManager
|
||||||
|
verwalter.itemStart(url)
|
||||||
|
|
||||||
|
const bild = new Image()
|
||||||
|
bild.onload = () => {
|
||||||
|
const kante = Math.min(bild.width, bild.height)
|
||||||
|
|
||||||
|
// Sichtbarer Durchmesser des Kreises in Bildpunkten der Leinwand
|
||||||
|
const abstand = Math.hypot(seat.x, CAMERA.z - seat.z)
|
||||||
|
const durchmesser = (seat.radius * leinwandHoehe) / (abstand * Math.tan((CAMERA.fov / 2) * Math.PI / 180))
|
||||||
|
// Etwas Reserve für Fenster, die grösser sind als das aktuelle
|
||||||
|
const ziel = Math.round(Math.min(kante, Math.max(160, durchmesser * 1.35)))
|
||||||
|
|
||||||
|
const flaeche = document.createElement('canvas')
|
||||||
|
flaeche.width = ziel
|
||||||
|
flaeche.height = ziel
|
||||||
|
const g = flaeche.getContext('2d')
|
||||||
|
if (g) {
|
||||||
|
g.imageSmoothingEnabled = true
|
||||||
|
g.imageSmoothingQuality = 'high'
|
||||||
|
g.drawImage(bild, (bild.width - kante) / 2, (bild.height - kante) / 2, kante, kante, 0, 0, ziel, ziel)
|
||||||
|
schaerfeNach(g, flaeche, ziel, kante)
|
||||||
|
tex.image = flaeche
|
||||||
|
} else {
|
||||||
|
tex.image = bild
|
||||||
|
}
|
||||||
|
tex.needsUpdate = true
|
||||||
|
verwalter.itemEnd(url)
|
||||||
|
}
|
||||||
|
bild.onerror = () => {
|
||||||
|
verwalter.itemError(url)
|
||||||
|
verwalter.itemEnd(url)
|
||||||
|
}
|
||||||
|
bild.src = url
|
||||||
|
|
||||||
|
return tex
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Agent am Tisch (§3).
|
||||||
|
*
|
||||||
|
* 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).
|
||||||
|
*
|
||||||
|
* 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(
|
function addSeatedAgent(
|
||||||
scene: THREE.Scene,
|
scene: THREE.Scene,
|
||||||
loader: THREE.TextureLoader,
|
|
||||||
seat: MeetingSeat,
|
seat: MeetingSeat,
|
||||||
photoUrl: string | undefined,
|
photoUrl: string | undefined,
|
||||||
suitColor: number,
|
maxAnisotropy: number,
|
||||||
|
leinwandHoehe: number,
|
||||||
): SeatHandle {
|
): SeatHandle {
|
||||||
const group = new THREE.Group()
|
const group = new THREE.Group()
|
||||||
group.position.set(seat.x, 0, seat.z)
|
group.position.set(seat.x, 0, seat.z)
|
||||||
|
|
||||||
// Blickrichtung: zum Kopfende des Betrachters hin, aber zur Tischmitte gedreht.
|
|
||||||
const yaw =
|
const yaw =
|
||||||
seat.side === SeatSide.HEAD ? 0
|
seat.side === SeatSide.HEAD ? 0
|
||||||
: seat.side === SeatSide.LEFT ? 0.62
|
: seat.side === SeatSide.LEFT ? 0.42
|
||||||
: -0.62
|
: -0.42
|
||||||
|
|
||||||
// ── Torso ──
|
// Radius und Höhe stammen vom Platz: näher sitzende Agenten haben einen
|
||||||
const torsoHeight = SEATED.torsoTop - SEATED.torsoBottom
|
// kleineren Kreis, erscheinen im Bild aber grösser (Runde 7, §2).
|
||||||
const torso = new THREE.Mesh(
|
const radius = seat.radius
|
||||||
new THREE.CylinderGeometry(SEATED.torsoRadiusTop, SEATED.torsoRadiusBottom, torsoHeight, 20, 1),
|
const kopfHoehe = TABLE.top + radius + SEATED.headGap
|
||||||
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()
|
const head = new THREE.Group()
|
||||||
head.position.y = SEATED.headY
|
head.position.y = kopfHoehe
|
||||||
head.rotation.y = yaw
|
head.rotation.y = yaw
|
||||||
|
|
||||||
const ring = new THREE.Mesh(
|
const ring = new THREE.Mesh(
|
||||||
new THREE.CircleGeometry(SEATED.headRadius + 0.018, 40),
|
new THREE.CircleGeometry(radius * 1.06, 48),
|
||||||
new THREE.MeshBasicMaterial({ color: ROOM_COLORS.headRing }),
|
new THREE.MeshBasicMaterial({ color: ROOM_COLORS.headRing }),
|
||||||
)
|
)
|
||||||
ring.position.z = -0.004
|
ring.position.z = -0.006
|
||||||
head.add(ring)
|
head.add(ring)
|
||||||
|
|
||||||
if (photoUrl) {
|
if (photoUrl) {
|
||||||
const tex = loader.load(photoUrl)
|
const tex = ladePortraet(photoUrl, seat, leinwandHoehe, maxAnisotropy)
|
||||||
tex.colorSpace = THREE.SRGBColorSpace
|
|
||||||
const face = new THREE.Mesh(
|
const face = new THREE.Mesh(
|
||||||
new THREE.CircleGeometry(SEATED.headRadius, 40),
|
new THREE.CircleGeometry(radius, 96),
|
||||||
new THREE.MeshBasicMaterial({ map: tex }),
|
new THREE.MeshBasicMaterial({ map: tex }),
|
||||||
)
|
)
|
||||||
head.add(face)
|
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)
|
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) ──
|
// ── Trefferkörper: gross genug für bequemes Klicken (§9) ──
|
||||||
const hitBox = new THREE.Mesh(
|
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(radius * 1.1, 12, 8),
|
||||||
// Unsichtbar, aber weiterhin vom Raycaster erfasst: `visible = false` würde
|
// Unsichtbar, aber weiterhin vom Raycaster erfasst: `visible = false` würde
|
||||||
// das Objekt aus der Strahlprüfung nehmen.
|
// das Objekt aus der Strahlprüfung nehmen.
|
||||||
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }),
|
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }),
|
||||||
)
|
)
|
||||||
hitBox.position.y = 0.9
|
hitBox.position.y = kopfHoehe
|
||||||
group.add(hitBox)
|
group.add(hitBox)
|
||||||
|
|
||||||
scene.add(group)
|
scene.add(group)
|
||||||
@@ -394,7 +482,7 @@ function addSeatedAgent(
|
|||||||
// die Plätze dort auf wenige Bildpunkte zusammenrücken (§8).
|
// die Plätze dort auf wenige Bildpunkte zusammenrücken (§8).
|
||||||
labelAnchor: new THREE.Vector3(
|
labelAnchor: new THREE.Vector3(
|
||||||
seat.x,
|
seat.x,
|
||||||
SEATED.headY + SEATED.headRadius + 0.16 + Math.min(0.78, Math.max(0, (Math.abs(seat.z) - 1.75) * 0.135)),
|
kopfHoehe + radius + 0.10 + Math.min(0.34, Math.max(0, (Math.abs(seat.z) - 0.9) * 0.06)),
|
||||||
seat.z,
|
seat.z,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -408,14 +496,22 @@ export function createMeetingRoomScene(
|
|||||||
photos: Record<string, string | undefined>,
|
photos: Record<string, string | undefined>,
|
||||||
): MeetingRoomScene {
|
): MeetingRoomScene {
|
||||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false })
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false })
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
/*
|
||||||
|
* Mindestens doppelte Auflösung, unabhängig vom Bildschirm.
|
||||||
|
*
|
||||||
|
* Wird intern feiner gerechnet als der Bildschirm darstellt, gewinnen alle
|
||||||
|
* feinen Kanten — Fensterprofile, Kulisse und vor allem die Porträtkreise.
|
||||||
|
* Das kostet hier wenig: die Szene wird nicht laufend, sondern nur bei
|
||||||
|
* Grössenänderung, Mausbewegung und Zustandswechsel neu gezeichnet.
|
||||||
|
*/
|
||||||
|
renderer.setPixelRatio(Math.min(Math.max(window.devicePixelRatio, 2), 3))
|
||||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||||
|
|
||||||
const scene = new THREE.Scene()
|
const scene = new THREE.Scene()
|
||||||
scene.background = new THREE.Color(ROOM_COLORS.sky)
|
scene.background = new THREE.Color(ROOM_COLORS.sky)
|
||||||
// Leichter Dunst nur in der Ferne: der Raum (0…12 m) bleibt klar, die Kulisse
|
// 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.
|
// 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)
|
const camera = new THREE.PerspectiveCamera(CAMERA.fov, 1, 0.1, 300)
|
||||||
camera.position.set(0, CAMERA.eyeHeight, CAMERA.z)
|
camera.position.set(0, CAMERA.eyeHeight, CAMERA.z)
|
||||||
@@ -432,14 +528,25 @@ export function createMeetingRoomScene(
|
|||||||
scene.add(bounce)
|
scene.add(bounce)
|
||||||
|
|
||||||
const loader = new THREE.TextureLoader()
|
const loader = new THREE.TextureLoader()
|
||||||
|
const maxAnisotropy = renderer.capabilities.getMaxAnisotropy()
|
||||||
|
|
||||||
addBaselBackdrop(scene, loader)
|
addBaselBackdrop(scene, loader, maxAnisotropy)
|
||||||
addRoomShell(scene)
|
addRoomShell(scene)
|
||||||
addGlassFacades(scene)
|
addGlassFacades(scene)
|
||||||
addTable(scene)
|
addTable(scene)
|
||||||
|
|
||||||
const handles = seats.map((seat, i) =>
|
/*
|
||||||
addSeatedAgent(scene, loader, seat, photos[seat.id], ROOM_COLORS.suits[i % ROOM_COLORS.suits.length]),
|
* Höhe der Leinwand in Bildpunkten, aus der die Porträts ihre Auflösung
|
||||||
|
* ableiten. Zum Zeitpunkt des Aufbaus steht sie noch nicht fest, weil die
|
||||||
|
* Grösse erst danach gesetzt wird. Der Ausgangswert deckt den üblichen
|
||||||
|
* Bereich ab; wächst das Fenster später deutlich, werden die Bilder etwas
|
||||||
|
* vergrössert dargestellt — weit weniger auffällig als eine zu grobe
|
||||||
|
* Zwischenfassung.
|
||||||
|
*/
|
||||||
|
const leinwandHoehe = Math.max(canvas.clientHeight, 700) * renderer.getPixelRatio()
|
||||||
|
|
||||||
|
const handles = seats.map(seat =>
|
||||||
|
addSeatedAgent(scene, seat, photos[seat.id], maxAnisotropy, leinwandHoehe),
|
||||||
)
|
)
|
||||||
|
|
||||||
const baseCam = camera.position.clone()
|
const baseCam = camera.position.clone()
|
||||||
@@ -472,7 +579,15 @@ export function createMeetingRoomScene(
|
|||||||
// schneidet das hohe Format den Tisch links und rechts ab. Statt einer
|
// schneidet das hohe Format den Tisch links und rechts ab. Statt einer
|
||||||
// eigenen Mobilszene (§18: pragmatische Degradation) öffnet sich die
|
// eigenen Mobilszene (§18: pragmatische Degradation) öffnet sich die
|
||||||
// Kamera, bis der Raum wieder hineinpasst.
|
// Kamera, bis der Raum wieder hineinpasst.
|
||||||
camera.fov = CAMERA.fov + Math.max(0, (1.45 - aspect) * 26)
|
const weit = CAMERA.fov + Math.max(0, (1.45 - aspect) * 26)
|
||||||
|
/*
|
||||||
|
* Nach oben begrenzt durch den waagrechten Bildwinkel. Bei sehr breiten,
|
||||||
|
* flachen Fenstern — etwa 1280 × 380 auf einem Notebook — griffe sonst ein
|
||||||
|
* waagrechter Winkel von über 110°: der Raum wirkt gezerrt, und der Blick
|
||||||
|
* reicht seitlich über den Rand des Panoramas hinaus.
|
||||||
|
*/
|
||||||
|
const maxTan = Math.tan((CAMERA.maxHFov / 2) * Math.PI / 180) / aspect
|
||||||
|
camera.fov = Math.min(weit, 2 * Math.atan(maxTan) * 180 / Math.PI)
|
||||||
camera.updateProjectionMatrix()
|
camera.updateProjectionMatrix()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -39,43 +39,68 @@ export interface MeetingSeat {
|
|||||||
/** Sitzposition in Metern. */
|
/** Sitzposition in Metern. */
|
||||||
x: number
|
x: number
|
||||||
z: number
|
z: number
|
||||||
|
/** Radius des Porträtkreises in Metern. */
|
||||||
|
radius: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Platz, so wie er unten eingetragen wird. */
|
||||||
|
type Platz = { side: SeatSide; x: number; z: number; radius: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sitzplätze der fünf Agenten, in Metern (Runde 6 §3.2, Runde 7 §2).
|
||||||
|
*
|
||||||
|
* Ferdi und Bruno sind die direkten Nachbarn des Betrachters — einer links,
|
||||||
|
* einer rechts, schräg frontal und dicht an der vorderen Tischkante. Dahinter
|
||||||
|
* staffeln sich die übrigen drei.
|
||||||
|
*
|
||||||
|
* Die Radien wachsen nach hinten, die **Bildgrösse** nimmt trotzdem mit der
|
||||||
|
* Tiefe ab: ein Kreis von 0.30 m in 5 m Entfernung erscheint kleiner als einer
|
||||||
|
* von 0.265 m in 3 m. Ohne diese Gegensteuerung wären die hinteren Porträts bei
|
||||||
|
* gleichem Radius kaum noch erkennbar (§2: «tiefenlogisch», aber «insgesamt klar
|
||||||
|
* besser sichtbar»).
|
||||||
|
*
|
||||||
|
* Alle Werte sind gemeinsam gewählt, nicht einzeln: mit den Kamerawerten aus
|
||||||
|
* `meetingRoomPalette` durchgerechnet ergeben sie gegenüber dem Stand vor dieser
|
||||||
|
* Runde die Faktoren 2.11× (Ferdi/Bruno), 2.22× (Livia/Nora), 2.50× (Sina) und
|
||||||
|
* 3.18× (Thomas) — und dabei **keine** einzige Überschneidung zweier Kreise im
|
||||||
|
* Bild. Wer einen Wert ändert, verschiebt beides; die Kreise überlappen dann
|
||||||
|
* schnell wieder.
|
||||||
|
*/
|
||||||
|
const PLAETZE: Record<string, Platz> = {
|
||||||
|
ferdi: { side: SeatSide.LEFT, x: -1.16, z: -0.88, radius: 0.268 },
|
||||||
|
bruno: { side: SeatSide.RIGHT, x: 1.16, z: -0.88, radius: 0.268 },
|
||||||
|
livia: { side: SeatSide.LEFT, x: -1.32, z: -3.35, radius: 0.345 },
|
||||||
|
nora: { side: SeatSide.RIGHT, x: 1.32, z: -3.35, radius: 0.345 },
|
||||||
|
sina: { side: SeatSide.LEFT, x: -0.95, z: -5.50, radius: 0.395 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstand der Plätze entlang des Tisches, in Metern. Bewusst grosszügig: bei
|
* Notplatz für einen Agenten ohne Eintrag oben — sichtbar hinten rechts statt
|
||||||
* engerer Staffelung überschneiden sich in der Perspektive die Köpfe der
|
* lautlos im Nichts.
|
||||||
* hinteren Plätze mit denen der vorderen (§5 «keine Personen überlappen»).
|
|
||||||
*/
|
*/
|
||||||
const SEAT_Z = [-1.75, -3.55, -5.35]
|
const FALLBACK: Platz = { side: SeatSide.RIGHT, x: 0.95, z: -5.50, radius: 0.395 }
|
||||||
|
|
||||||
/** Seitlicher Abstand von der Tischmitte — knapp aussen an der Tischkante. */
|
/**
|
||||||
const SEAT_X = 1.32
|
* Kopfende gegenüber. Position unverändert, nur der Kreis wächst kräftig: von
|
||||||
|
* dort aus ist der Abstand am grössten, und Thomas war zuvor kaum auszumachen
|
||||||
/** Kopfende gegenüber: hinter der Tischkante, vor der Glasfront. */
|
* (§2). Grösser darf er nicht werden — sein Kreis überträfe im Bild sonst den
|
||||||
const HEAD_Z = -8.15
|
* von Sina, die näher sitzt, und die Tiefenwirkung kippte.
|
||||||
|
*/
|
||||||
|
const KOPFENDE: Platz = { side: SeatSide.HEAD, x: 0, z: -6.75, radius: 0.452 }
|
||||||
|
|
||||||
export const MEETING_SEATS: MeetingSeat[] = [
|
export const MEETING_SEATS: MeetingSeat[] = [
|
||||||
...AGENT_WORKSPACES.map((agent, i) => {
|
...AGENT_WORKSPACES.map(agent => ({
|
||||||
const left = i % 2 === 0
|
id: agent.id,
|
||||||
return {
|
name: agent.name,
|
||||||
id: agent.id,
|
role: agent.role,
|
||||||
name: agent.name,
|
path: agent.path,
|
||||||
role: agent.role,
|
...(PLAETZE[agent.id] ?? FALLBACK),
|
||||||
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),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
id: AGENT_SUPERVISOR.id,
|
id: AGENT_SUPERVISOR.id,
|
||||||
name: AGENT_SUPERVISOR.name,
|
name: AGENT_SUPERVISOR.name,
|
||||||
role: AGENT_SUPERVISOR.role,
|
role: AGENT_SUPERVISOR.role,
|
||||||
path: AGENT_SUPERVISOR.path,
|
path: AGENT_SUPERVISOR.path,
|
||||||
side: SeatSide.HEAD,
|
...KOPFENDE,
|
||||||
x: 0,
|
|
||||||
z: HEAD_Z,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||