Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55274488d1 | ||
|
|
403161283c |
No files matched your search
@@ -11,7 +11,7 @@
|
||||
* 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.
|
||||
* 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
|
||||
@@ -49,7 +49,17 @@ const FRONT_WATER = 0.60
|
||||
const LEFT_WATER = 0.64
|
||||
/** Skalierung der beiden Quellen. */
|
||||
const FRONT_SCALE = 1.3
|
||||
const LEFT_SCALE = 2.45
|
||||
/**
|
||||
* 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.
|
||||
@@ -61,8 +71,43 @@ const LEFT_SCALE = 2.45
|
||||
* vor, nämlich im Mittelmotiv vom Tischende.
|
||||
*/
|
||||
const LEFT_CROP = { sx: 330, sw: 370 }
|
||||
/** Breite der Überblendung zwischen Fortsetzung und Mittelmotiv. */
|
||||
const FEATHER = 320
|
||||
/**
|
||||
* 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,
|
||||
@@ -90,39 +135,24 @@ const result = await page.evaluate(async (cfg) => {
|
||||
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
|
||||
// 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
|
||||
|
||||
/** 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.
|
||||
* 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, brightness) {
|
||||
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)
|
||||
@@ -135,17 +165,55 @@ const result = await page.evaluate(async (cfg) => {
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -153,33 +221,271 @@ const result = await page.evaluate(async (cfg) => {
|
||||
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)
|
||||
// 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) Mittelmotiv links, unverändert, mit weicher rechter Flanke.
|
||||
const vorne = schicht(fImg, null, cfg.FRONT_SCALE, cfg.FRONT_WATER, 1)
|
||||
// 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'
|
||||
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)
|
||||
mg.drawImage(maske, 0, 0)
|
||||
g.drawImage(mid, 0, 0)
|
||||
|
||||
return { dataUri: cv.toDataURL('image/jpeg', 0.84), W, H, korrektur }
|
||||
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, LEFT_CROP,
|
||||
H, WATER_Y, FRONT_WATER, LEFT_WATER, FRONT_SCALE, LEFT_SCALE, FEATHER, FEATHER_WEICH, BAND, LEFT_CROP, SCHAERFE,
|
||||
})
|
||||
|
||||
await browser.close()
|
||||
@@ -187,4 +493,4 @@ 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)}`)
|
||||
console.log(` ${result.W}x${result.H}, ${kb} KB`)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 144 KiB |
@@ -32,10 +32,6 @@ export const ROOM_COLORS = {
|
||||
tableTop: 0x8f7350,
|
||||
tableEdge: 0x6f5637,
|
||||
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. */
|
||||
headRing: 0xffffff,
|
||||
/** Ring bei Hover: Gold aus dem Designsystem. */
|
||||
@@ -61,6 +57,12 @@ export const CAMERA = {
|
||||
/** Knapp hinter der vorderen Tischkante. */
|
||||
z: 1.9,
|
||||
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. */
|
||||
lookAt: [0, 1.24, -5.2] as const,
|
||||
/**
|
||||
@@ -82,23 +84,19 @@ export const TABLE = {
|
||||
zFar: -5.75,
|
||||
} as const
|
||||
|
||||
/** Sitzende Personen: Kopfhöhe und Grössen. */
|
||||
/** Sitzende Personen: Höhe der Porträtkreise. */
|
||||
export const SEATED = {
|
||||
/**
|
||||
* Höhe der Porträtkreise über dem Boden.
|
||||
* Luft zwischen Tischplatte und Unterkante des Porträtkreises (Runde 7, §2).
|
||||
*
|
||||
* 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).
|
||||
* Daraus ergibt sich die Kopfhöhe: `TABLE.top + radius + headGap`. Die Kreise
|
||||
* schweben damit nur knapp über der Tischkante statt weit darüber. Vorher
|
||||
* hingen sie fest auf 1.52 m — bei den jetzt deutlich grösseren Kreisen sähe
|
||||
* das aus, als schwebten Köpfe frei im Raum.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
headY: 1.52,
|
||||
headRadius: 0.142,
|
||||
/** Torso vom Stuhl bis zum Hals. */
|
||||
torsoTop: 1.12,
|
||||
torsoBottom: 0.42,
|
||||
torsoRadiusTop: 0.19,
|
||||
torsoRadiusBottom: 0.25,
|
||||
shoulderWidth: 0.46,
|
||||
headGap: 0.05,
|
||||
} as const
|
||||
@@ -123,7 +123,9 @@ function addRoomShell(scene: THREE.Scene) {
|
||||
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.wallSkirting }),
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -148,16 +150,27 @@ function addRoomShell(scene: THREE.Scene) {
|
||||
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 = 2.62
|
||||
/** Höhe passend zum Seitenverhältnis des Panoramas (1523 × 660). */
|
||||
const height = (radius * arc) / (1523 / 660)
|
||||
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 / 660
|
||||
const waterFraction = 400 / panorama.hoehe
|
||||
/** Höhe der Rheinoberfläche relativ zum Bürofussboden — wir sind im Hochhaus. */
|
||||
const waterY = -7.5
|
||||
|
||||
@@ -179,7 +192,7 @@ function addBaselBackdrop(scene: THREE.Scene, loader: THREE.TextureLoader, maxAn
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
@@ -279,6 +292,113 @@ function addTable(scene: THREE.Scene) {
|
||||
scene.add(shadow)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nachschärfen nach dem Verkleinern (Unschärfemaske).
|
||||
*
|
||||
* Beim Herunterrechnen eines Fotos verschwimmen Kanten — Wimpern, Brillenrand,
|
||||
* einzelne Haare. Das Bild minus seiner weichgezeichneten Fassung ergibt genau
|
||||
* diese Kantenzeichnung; verstärkt zurückgegeben holt sie die Zeichnung wieder
|
||||
* heran. Jedes Bildbearbeitungsprogramm macht das nach einer Verkleinerung.
|
||||
*
|
||||
* Die Stärke richtet sich danach, wie weit verkleinert wurde: wer kaum
|
||||
* verkleinert, braucht kaum Schärfung, sonst wirkt das Ergebnis überzeichnet.
|
||||
*/
|
||||
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).
|
||||
*
|
||||
@@ -286,21 +406,16 @@ function addTable(scene: THREE.Scene) {
|
||||
* keine Arme. Die früheren Sitzmöbel und Rümpfe wirkten unnatürlich und sind
|
||||
* ersatzlos entfallen (§3.1).
|
||||
*
|
||||
* 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,
|
||||
maxAnisotropy: number,
|
||||
leinwandHoehe: number,
|
||||
): SeatHandle {
|
||||
const group = new THREE.Group()
|
||||
group.position.set(seat.x, 0, seat.z)
|
||||
@@ -310,40 +425,27 @@ function addSeatedAgent(
|
||||
: seat.side === SeatSide.LEFT ? 0.42
|
||||
: -0.42
|
||||
|
||||
// Radius und Höhe stammen vom Platz: näher sitzende Agenten haben einen
|
||||
// kleineren Kreis, erscheinen im Bild aber grösser (Runde 7, §2).
|
||||
const radius = seat.radius
|
||||
const kopfHoehe = TABLE.top + radius + SEATED.headGap
|
||||
|
||||
const head = new THREE.Group()
|
||||
head.position.y = SEATED.headY
|
||||
head.position.y = kopfHoehe
|
||||
head.rotation.y = yaw
|
||||
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.CircleGeometry(SEATED.headRadius + 0.016, 48),
|
||||
new THREE.CircleGeometry(radius * 1.06, 48),
|
||||
new THREE.MeshBasicMaterial({ color: ROOM_COLORS.headRing }),
|
||||
)
|
||||
ring.position.z = -0.004
|
||||
ring.position.z = -0.006
|
||||
head.add(ring)
|
||||
|
||||
if (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 tex = ladePortraet(photoUrl, seat, leinwandHoehe, maxAnisotropy)
|
||||
|
||||
const face = new THREE.Mesh(
|
||||
new THREE.CircleGeometry(SEATED.headRadius, 48),
|
||||
new THREE.CircleGeometry(radius, 96),
|
||||
new THREE.MeshBasicMaterial({ map: tex }),
|
||||
)
|
||||
head.add(face)
|
||||
@@ -360,12 +462,12 @@ function addSeatedAgent(
|
||||
* 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),
|
||||
new THREE.SphereGeometry(radius * 1.1, 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 = SEATED.headY
|
||||
hitBox.position.y = kopfHoehe
|
||||
group.add(hitBox)
|
||||
|
||||
scene.add(group)
|
||||
@@ -380,7 +482,7 @@ function addSeatedAgent(
|
||||
// die Plätze dort auf wenige Bildpunkte zusammenrücken (§8).
|
||||
labelAnchor: new THREE.Vector3(
|
||||
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,
|
||||
),
|
||||
}
|
||||
@@ -394,7 +496,15 @@ export function createMeetingRoomScene(
|
||||
photos: Record<string, string | undefined>,
|
||||
): MeetingRoomScene {
|
||||
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
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
@@ -425,8 +535,18 @@ export function createMeetingRoomScene(
|
||||
addGlassFacades(scene)
|
||||
addTable(scene)
|
||||
|
||||
/*
|
||||
* 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, loader, seat, photos[seat.id], maxAnisotropy),
|
||||
addSeatedAgent(scene, seat, photos[seat.id], maxAnisotropy, leinwandHoehe),
|
||||
)
|
||||
|
||||
const baseCam = camera.position.clone()
|
||||
@@ -459,7 +579,15 @@ export function createMeetingRoomScene(
|
||||
// schneidet das hohe Format den Tisch links und rechts ab. Statt einer
|
||||
// eigenen Mobilszene (§18: pragmatische Degradation) öffnet sich die
|
||||
// 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()
|
||||
},
|
||||
|
||||
|
||||
@@ -39,34 +39,54 @@ export interface MeetingSeat {
|
||||
/** Sitzposition in Metern. */
|
||||
x: 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).
|
||||
* 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. Dadurch
|
||||
* entsteht hinter ihnen Platz, in dem die übrigen drei gestaffelt Raum haben,
|
||||
* ohne sich zu überlagern.
|
||||
* einer rechts, schräg frontal und dicht an der vorderen Tischkante. Dahinter
|
||||
* staffeln sich die übrigen drei.
|
||||
*
|
||||
* Vorher standen alle fünf zu weit hinten und drängten sich am Fluchtpunkt.
|
||||
* 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, { side: SeatSide; x: number; z: number }> = {
|
||||
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 },
|
||||
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 },
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
const FALLBACK: Platz = { side: SeatSide.RIGHT, x: 0.95, z: -5.50, radius: 0.395 }
|
||||
|
||||
/** Kopfende gegenüber. Unverändert: die Position wirkt gut (§3.2). */
|
||||
const HEAD_Z = -6.75
|
||||
/**
|
||||
* 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
|
||||
* (§2). Grösser darf er nicht werden — sein Kreis überträfe im Bild sonst den
|
||||
* 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[] = [
|
||||
...AGENT_WORKSPACES.map(agent => ({
|
||||
@@ -81,8 +101,6 @@ export const MEETING_SEATS: MeetingSeat[] = [
|
||||
name: AGENT_SUPERVISOR.name,
|
||||
role: AGENT_SUPERVISOR.role,
|
||||
path: AGENT_SUPERVISOR.path,
|
||||
side: SeatSide.HEAD,
|
||||
x: 0,
|
||||
z: HEAD_Z,
|
||||
...KOPFENDE,
|
||||
},
|
||||
]
|
||||
Reference in new issue
Block a user