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