From f9b0fb04968d79a8af64214d2899644f71cd5672 Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Mon, 3 Aug 2026 17:16:54 +0300 Subject: [PATCH] Rework Gothic hall as a stone nave interior. Add gothic ashlar/vault/floor textures, compound piers with transverse ribs, proper lancet arches, textured door-flanking panels, and lightScale for shaft-lit naves. Docs updated in basics and data-and-images. Co-authored-by: Cursor --- Documentation/basics.md | 8 +- Documentation/data-and-images.md | 4 +- client/src/components/GalleryWindows.tsx | 54 +++- client/src/components/MovementHallDetails.tsx | 111 +++++-- client/src/components/VirtualGallery.tsx | 52 +++- client/src/data/movement-interior-styles.ts | 40 +-- client/src/utils/galleryProceduralTextures.ts | 293 ++++++++++++++++++ 7 files changed, 501 insertions(+), 61 deletions(-) diff --git a/Documentation/basics.md b/Documentation/basics.md index 90c8ad3..4e7541d 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -336,11 +336,13 @@ Enter from the home page by clicking a **movement name** on the movement flow. F | Wall order | Same U-shaped hang as artist halls (left → end → right) | | Frame captions | **Year · artist** label below each frame | | Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, Byzantine basilica, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **1–26** → authored keys **27–52**, with Northern/High Renaissance swapped) so Byzantine gets the basilica, not Gothic stone | -| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco, **marble revetment** (framed book-matched panels), **Cosmatesque paving** (`marble-opus-sectile`), **coffered timber** ceilings — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes | +| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco, **marble revetment** (framed book-matched panels), **Cosmatesque paving** (`marble-opus-sectile`), **coffered timber** ceilings, **blind-arcaded ashlar** (`gothic-ashlar`) and **ribbed vaulting** (`gothic-vault`) — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes | +| Architecture in texture | Period halls that need arcades, vaults, or panelling draw that **relief into the texture** rather than adding geometry: `gothic-ashlar` paints a full storey (plinth → blind arcade → string course → triforium → cornice) into one tile whose `metersPerRepeat` equals `WALL_HEIGHT`, so it maps **once vertically** onto the wall; `gothic-vault` paints a quadripartite bay with tiercerons and a boss. Light/dark banding plus a high `normalStrengthFor()` makes it read as carved stone, keeping the mesh and light budget flat | +| Door-flanking panels | Wall segments beside exit doors and passages take the hall's own wall texture (`GalleryWall` accepts `kind`/`tint`) instead of rendering as flat single-colour blocks next to textured walls | | Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue | -| Windows | **Side walls only** — placed in gaps between frames when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`) | +| Windows | **Side walls only** — placed in gaps between frames when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`). Styles are drawn in `GalleryWindows.tsx`; `gothic-lancet` builds a two-centred arch head from chords with a glazed spandrel, mullions, and transoms | | Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below | -| Period details | `MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** (never freestanding mid-hall or proud corner shafts that cover frames); `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela | +| Period details | `MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** (never freestanding mid-hall or proud corner shafts that cover frames); `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela; `gothic` adds bay-spaced compound piers with vault springers, transverse ribs arching across the nave, and a moulded string course — all flush to the side walls | | Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors** → **Wing navigator** or **Exit to Timeline** | | Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists | | Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true | diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md index 6b905c6..65b18f6 100644 --- a/Documentation/data-and-images.md +++ b/Documentation/data-and-images.md @@ -278,7 +278,9 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli ## Movement gallery interiors (frontend) -Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (pilasters, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`). Period mesh details live in `client/src/components/MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** so shafts never cover frames. +Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (pilasters, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`). Period mesh details live in `client/src/components/MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** so shafts never cover frames; Byzantine adds porphyry colonnettes / polycandela; Gothic (`details: 'gothic'`) adds bay-spaced compound piers with vault springers and transverse ribs flush to the side walls. + +Gothic surfaces include **`gothic-ashlar`** (full-storey blind arcade mapped once vertically via `metersPerRepeat === WALL_HEIGHT`), **`gothic-vault`** (quadripartite rib bay), and **`gothic-stone-floor`**. Door-flanking wall panels use the hall wall texture via `GalleryWall` `kind`/`tint` so they match the textured walls. Deliberately dim naves set **`lightScale`** (Byzantine / Gothic) so shared lights stay soft while window shafts stay bright. Wing layout (up to 55 works per wing, U-shaped hang, window gap placement with packed-wall **clerestory** fallback) lives in `client/src/utils/movementHallLayout.ts`. Visit order fills the **left wall** (first work at the entrance), then the **far/end wall**, then the **right** (last work at the entrance). The same hang applies to artist halls and guided tours. Hall lighting is **shared** (no per-painting spotlights) so `MeshStandardMaterial` walls stay within WebGL light limits — see [basics.md](basics.md) § Shared 3D behaviour. To change a movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client. diff --git a/client/src/components/GalleryWindows.tsx b/client/src/components/GalleryWindows.tsx index a5a20e1..fce8453 100644 --- a/client/src/components/GalleryWindows.tsx +++ b/client/src/components/GalleryWindows.tsx @@ -69,10 +69,56 @@ function WindowFrame({ {arch && style === 'gothic-lancet' && ( - - - - + <> + {/* Two-centred arch head built from short chords */} + {Array.from({ length: 14 }, (_, i) => { + const segs = 14; + const rise = width * 0.62; + const pt = (t: number): [number, number] => { + const x = (t - 0.5) * (width + frameW * 2); + const y = Math.pow(Math.cos((t - 0.5) * Math.PI), 0.72) * rise; + return [x, y]; + }; + const [x0, y0] = pt(i / segs); + const [x1, y1] = pt((i + 1) / segs); + const len = Math.hypot(x1 - x0, y1 - y0); + return ( + + + + + ); + })} + {/* Glazed spandrel filling the arch head */} + + + + + {/* Mullions and transoms */} + {[-width / 4, width / 4].map((ox) => ( + + + + + ))} + {[height / 3, -height / 6].map((oy) => ( + + + + + ))} + )} {style === 'roman-arch' && ( diff --git a/client/src/components/MovementHallDetails.tsx b/client/src/components/MovementHallDetails.tsx index 43bd15c..9dfef1a 100644 --- a/client/src/components/MovementHallDetails.tsx +++ b/client/src/components/MovementHallDetails.tsx @@ -100,41 +100,94 @@ function BaroqueDetails({ width, depth, halfW, halfD, trim }: Props & { trim: st ); } -function MedievalDetails({ halfW, halfD }: Pick) { - const torchPositions = useMemo( - () => - [ - [-halfW + 0.2, -halfD * 0.5], - [halfW - 0.2, -halfD * 0.5], - [-halfW + 0.2, halfD * 0.3], - [halfW - 0.2, halfD * 0.3], - ] as [number, number][], - [halfW, halfD] - ); +function GothicDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) { + // Compound piers along the side walls, spaced by bay, each rising into the + // vault springer. Shafts stay flush against the wall (0.16 m proud) so they + // never intrude on the ~0.7 m frame hang margin. + const bays = useMemo(() => { + const spacing = 4.2; + const count = Math.max(2, Math.min(9, Math.floor(depth / spacing))); + const step = depth / (count + 1); + return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1)); + }, [depth, halfD]); + + const shaftHeight = 3.5; return ( - {torchPositions.map(([x, z], i) => ( - - - - - - - - - - + {bays.map((z, i) => ( + + {[-1, 1].map((side) => ( + + {/* Compound pier: a heavier centre shaft flanked by colonnettes */} + + + + + {[-0.17, 0.17].map((dz) => ( + + + + + ))} + {/* Moulded base and foliate capital */} + + + + + + + + + {/* Vault springer resting on the capital */} + + + + + + ))} + + {/* Transverse rib arching across the nave between opposite piers */} + + {Array.from({ length: 9 }, (_, s) => { + const segs = 9; + const t0 = s / segs; + const t1 = (s + 1) / segs; + const xAt = (t: number) => -halfW + 0.09 + t * (halfW - 0.09) * 2; + const yAt = (t: number) => Math.sin(Math.PI * t) * 0.34; + const x0 = xAt(t0); + const x1 = xAt(t1); + const y0 = yAt(t0); + const y1 = yAt(t1); + const len = Math.hypot(x1 - x0, y1 - y0); + return ( + + + + + ); + })} + ))} - {/* Rough stone courses */} - {[-halfD + 0.08, halfD - 0.08].map((z, i) => ( - - - + {/* Moulded string course running the length of both side walls */} + {[-1, 1].map((side) => ( + + + ))} + + {/* Chancel-style cornice on the end wall */} + + + + ); } @@ -324,8 +377,8 @@ export default function MovementHallDetails({ style, width, depth, halfW, halfD return ; case 'baroque': return ; - case 'medieval': - return ; + case 'gothic': + return ; case 'byzantine': return ; case 'neoclassical': diff --git a/client/src/components/VirtualGallery.tsx b/client/src/components/VirtualGallery.tsx index 5c7ae0b..589dccf 100644 --- a/client/src/components/VirtualGallery.tsx +++ b/client/src/components/VirtualGallery.tsx @@ -14,7 +14,7 @@ import type { } from '../types'; import { galleryImageUrlCandidates, imageUrl, api } from '../api/client'; import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils'; -import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures'; +import { cloneSurfaceTexture, getSurfaceTexture, type SurfaceTextureKind } from '../utils/galleryProceduralTextures'; import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles'; import { useTexturedMaterial } from '../hooks/useTexturedMaterial'; import MovementHallDetails from './MovementHallDetails'; @@ -1059,13 +1059,31 @@ function GalleryWall({ size, rotation = [0, 0, 0], color = '#ebe4d8', + kind, + tint, }: { position: [number, number, number]; size: [number, number]; rotation?: [number, number, number]; color?: string; + /** When set, the panel is textured like the rest of the hall instead of flat colour. */ + kind?: SurfaceTextureKind; + tint?: string; }) { const [w, h] = size; + // Door-flanking panels would otherwise read as flat single-colour blocks + // beside fully textured walls. + if (kind) { + return ( + + ); + } return ( @@ -1563,16 +1581,22 @@ function ArtistHall({ position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main} + kind={interiorStyle?.surfaces.wall} + tint={interiorStyle?.tints.wall} /> ) : movementMode && endWallHasDoor ? ( - + ) : ( <> - - - + + + ) : ( - + ) ) : null} diff --git a/client/src/data/movement-interior-styles.ts b/client/src/data/movement-interior-styles.ts index 8eed6cc..c2b4f69 100644 --- a/client/src/data/movement-interior-styles.ts +++ b/client/src/data/movement-interior-styles.ts @@ -54,7 +54,7 @@ export interface MovementInteriorStyle { trackLights: number; /** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */ lightScale: number; - details: 'palazzo' | 'baroque' | 'medieval' | 'byzantine' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier'; + details: 'palazzo' | 'baroque' | 'gothic' | 'byzantine' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier'; } function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] { @@ -144,26 +144,30 @@ const BY_MOVEMENT_ID: Record = { ), 29: mk( 'gothic-cathedral', - 'Gothic hall', - 'Stone vault · tall lancet windows · flagstone floor', - { wall: 'rough-stone', ceiling: 'basalt', floor: 'flagstone' }, - { wall: '#8a8478', ceiling: '#3a3630', floor: '#6a6458', trim: '#5c5648' }, + 'Gothic nave', + 'Blind-arcaded ashlar · ribbed stone vault · worn flagstones', + { wall: 'gothic-ashlar', ceiling: 'gothic-vault', floor: 'gothic-stone-floor' }, + { wall: '#8d8471', ceiling: '#6d6555', floor: '#6f6759', trim: '#7d7159' }, { - details: 'medieval', - titleColor: '#e8dcc8', - ambient: 0.72, - warmLight: '#e8d8c0', - sunLight: '#d0e8ff', - // Keep atmosphere dark, but not pure void (walls must stay readable). - fog: '#1a1816', - background: '#141210', + details: 'gothic', + titleColor: '#efe4cc', + ambient: 0.6, + warmLight: '#ffe6bc', + sunLight: '#dbe8ff', + // Cool shadowed stone, but never a pure void — walls must stay readable. + fog: '#1b1a18', + background: '#131211', windows: windows( - { wall: 'back', x: -2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 }, - { wall: 'back', x: 2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 }, - { wall: 'left', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 }, - { wall: 'right', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 } + { wall: 'back', x: -2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.8 }, + { wall: 'back', x: 2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.8 }, + { wall: 'left', x: -4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 }, + { wall: 'left', x: 4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 }, + { wall: 'right', x: -4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 }, + { wall: 'right', x: 4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 } ), - trackLights: 0.85, + trackLights: 0.5, + // Naves are lit by window shafts against shadowed stone, not flooded. + lightScale: 0.26, } ), 30: mk( diff --git a/client/src/utils/galleryProceduralTextures.ts b/client/src/utils/galleryProceduralTextures.ts index 7eb7d26..12f432e 100644 --- a/client/src/utils/galleryProceduralTextures.ts +++ b/client/src/utils/galleryProceduralTextures.ts @@ -14,6 +14,9 @@ export type SurfaceTextureKind = | 'limestone' | 'sandstone' | 'rough-stone' + | 'gothic-ashlar' + | 'gothic-vault' + | 'gothic-stone-floor' | 'basalt' | 'stucco-cream' | 'stucco-terracotta' @@ -247,6 +250,52 @@ function marbleVeined(ctx: CanvasRenderingContext2D, size: number, base: string, noiseOverlay(ctx, size, 0.035, seed + 1); } +/** + * Two-centred (pointed) arch path from the springing line up to the apex. + * `springY` is the bottom of the curve, `apexY` the crown — canvas y grows down. + */ +function pointedArchPath( + ctx: CanvasRenderingContext2D, + cx: number, + halfW: number, + springY: number, + apexY: number +) { + const shoulder = apexY + (springY - apexY) * 0.28; + ctx.moveTo(cx - halfW, springY); + ctx.quadraticCurveTo(cx - halfW, shoulder, cx, apexY); + ctx.quadraticCurveTo(cx + halfW, shoulder, cx + halfW, springY); +} + +/** Coursed ashlar masonry — staggered blocks with cut joints and per-block tone. */ +function ashlarCourses( + ctx: CanvasRenderingContext2D, + size: number, + base: string, + seed: number, + courses = 12 +) { + fill(ctx, size, base); + const [br, bg, bb] = hexToRgb(base); + const ch = size / courses; + const bw = size / 6; + for (let row = 0; row < courses; row++) { + const offset = row % 2 ? bw / 2 : 0; + for (let col = -1; col < 7; col++) { + const rand = seeded(seed + row * 11 + col * 3); + const t = (rand() - 0.5) * 22; + ctx.fillStyle = rgb(br + t, bg + t, bb + t * 0.9); + ctx.fillRect(col * bw + offset, row * ch, bw - 1.5, ch - 1.5); + } + // Recessed bed joint with a lit lower lip + ctx.fillStyle = 'rgba(60,52,40,0.35)'; + ctx.fillRect(0, row * ch + ch - 1.5, size, 1.5); + ctx.fillStyle = 'rgba(255,250,235,0.16)'; + ctx.fillRect(0, row * ch, size, 1); + } + noiseOverlay(ctx, size, 0.05, seed + 1); +} + /** Veined marble confined to one slab rect — for revetment panels and inlay. */ function marbleSlab( ctx: CanvasRenderingContext2D, @@ -572,6 +621,235 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData { fill(ctx, size, '#3a3834'); noiseOverlay(ctx, size, 0.15, 107); break; + case 'gothic-ashlar': { + // One tile spans a full wall height: plinth, tall blind arcade, string + // course, triforium gallery, cornice — so the wall reads as architecture + // rather than a flat stone panel. Relief comes through the normal map. + ashlarCourses(ctx, size, '#c9bda6', 210, 13); + + const stringCourse = (y: number, h: number) => { + ctx.fillStyle = 'rgba(250,244,228,0.5)'; + ctx.fillRect(0, y, size, h * 0.45); + ctx.fillStyle = 'rgba(74,64,50,0.45)'; + ctx.fillRect(0, y + h * 0.45, size, h * 0.55); + }; + + // Plinth and cornice + ctx.fillStyle = 'rgba(60,52,40,0.22)'; + ctx.fillRect(0, size * 0.9, size, size * 0.1); + stringCourse(size * 0.885, size * 0.022); + stringCourse(size * 0.055, size * 0.028); + stringCourse(size * 0.375, size * 0.024); + + // Tall blind arcade — 3 bays across the tile + const bays = 3; + const bayW = size / bays; + for (let i = 0; i < bays; i++) { + const cx = bayW * (i + 0.5); + const halfW = bayW * 0.36; + const springY = size * 0.66; + const apexY = size * 0.43; + + // Recessed panel behind the arch + ctx.save(); + ctx.beginPath(); + pointedArchPath(ctx, cx, halfW, springY, apexY); + ctx.lineTo(cx + halfW, size * 0.885); + ctx.lineTo(cx - halfW, size * 0.885); + ctx.closePath(); + ctx.clip(); + ctx.fillStyle = 'rgba(52,44,34,0.34)'; + ctx.fillRect(cx - halfW, apexY, halfW * 2, size); + const shade = ctx.createLinearGradient(cx - halfW, 0, cx + halfW, 0); + shade.addColorStop(0, 'rgba(20,16,12,0.4)'); + shade.addColorStop(0.45, 'rgba(20,16,12,0)'); + shade.addColorStop(1, 'rgba(20,16,12,0.28)'); + ctx.fillStyle = shade; + ctx.fillRect(cx - halfW, apexY, halfW * 2, size); + ctx.restore(); + + // Arch moulding: lit outer roll, dark soffit + ctx.beginPath(); + pointedArchPath(ctx, cx, halfW + size * 0.016, springY, apexY - size * 0.02); + ctx.strokeStyle = 'rgba(252,246,230,0.62)'; + ctx.lineWidth = size * 0.016; + ctx.stroke(); + ctx.beginPath(); + pointedArchPath(ctx, cx, halfW, springY, apexY); + ctx.strokeStyle = 'rgba(58,48,36,0.5)'; + ctx.lineWidth = size * 0.008; + ctx.stroke(); + + // Colonnette shafts carrying the arch + for (const sx of [cx - halfW, cx + halfW]) { + ctx.fillStyle = 'rgba(250,244,228,0.5)'; + ctx.fillRect(sx - size * 0.011, springY, size * 0.014, size * 0.225); + ctx.fillStyle = 'rgba(58,48,36,0.42)'; + ctx.fillRect(sx + size * 0.003, springY, size * 0.005, size * 0.225); + // Moulded capital and base + ctx.fillStyle = 'rgba(252,246,230,0.62)'; + ctx.fillRect(sx - size * 0.019, springY - size * 0.016, size * 0.038, size * 0.016); + ctx.fillRect(sx - size * 0.017, size * 0.868, size * 0.034, size * 0.017); + } + } + + // Triforium gallery — paired small arches per bay + for (let i = 0; i < bays; i++) { + const bayCx = bayW * (i + 0.5); + for (const sub of [-1, 1]) { + const cx = bayCx + sub * bayW * 0.19; + const halfW = bayW * 0.13; + const springY = size * 0.29; + const apexY = size * 0.135; + ctx.save(); + ctx.beginPath(); + pointedArchPath(ctx, cx, halfW, springY, apexY); + ctx.lineTo(cx + halfW, size * 0.345); + ctx.lineTo(cx - halfW, size * 0.345); + ctx.closePath(); + ctx.clip(); + ctx.fillStyle = 'rgba(28,24,18,0.55)'; + ctx.fillRect(cx - halfW, apexY, halfW * 2, size * 0.3); + ctx.restore(); + ctx.beginPath(); + pointedArchPath(ctx, cx, halfW + size * 0.009, springY, apexY - size * 0.012); + ctx.strokeStyle = 'rgba(252,246,230,0.58)'; + ctx.lineWidth = size * 0.009; + ctx.stroke(); + for (const sx of [cx - halfW, cx + halfW]) { + ctx.fillStyle = 'rgba(250,244,228,0.5)'; + ctx.fillRect(sx - size * 0.006, springY, size * 0.008, size * 0.055); + } + } + } + noiseOverlay(ctx, size, 0.035, 211); + break; + } + case 'gothic-vault': { + // Quadripartite rib vault with tiercerons, seen from below: webbing + // panels lift toward a gilded central boss. + fill(ctx, size, '#cec4ac'); + const c = size / 2; + const glow = ctx.createRadialGradient(c, c, size * 0.05, c, c, size * 0.62); + glow.addColorStop(0, 'rgba(255,250,236,0.55)'); + glow.addColorStop(1, 'rgba(38,32,25,0.72)'); + ctx.fillStyle = glow; + ctx.fillRect(0, 0, size, size); + for (let y = 0; y < size; y += 4) { + for (let x = 0; x < size; x += 4) { + const n = (fbm(x / 90, y / 90, 221) - 0.5) * 14; + ctx.fillStyle = `rgba(${210 + n | 0},${200 + n | 0},${176 + n | 0},0.35)`; + ctx.fillRect(x, y, 4, 4); + } + } + + const rib = (x0: number, y0: number, x1: number, y1: number, w: number) => { + ctx.lineCap = 'round'; + ctx.strokeStyle = 'rgba(48,40,30,0.5)'; + ctx.lineWidth = w * 1.55; + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + ctx.stroke(); + ctx.strokeStyle = 'rgba(236,228,206,0.95)'; + ctx.lineWidth = w; + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + ctx.stroke(); + ctx.strokeStyle = 'rgba(255,252,242,0.75)'; + ctx.lineWidth = w * 0.32; + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + ctx.stroke(); + }; + + const major = size * 0.044; + const minor = size * 0.026; + // Transverse and wall ribs around the bay + rib(0, 0, size, 0, major); + rib(0, size, size, size, major); + rib(0, 0, 0, size, major); + rib(size, 0, size, size, major); + // Diagonal cross ribs + rib(0, 0, size, size, major); + rib(size, 0, 0, size, major); + // Tiercerons springing to the edge midpoints + rib(0, 0, c, 0, minor); + rib(0, 0, 0, c, minor); + rib(size, 0, c, 0, minor); + rib(size, 0, size, c, minor); + rib(0, size, c, size, minor); + rib(0, size, 0, c, minor); + rib(size, size, c, size, minor); + rib(size, size, size, c, minor); + // Ridge ribs + rib(c, 0, c, size, minor); + rib(0, c, size, c, minor); + + // Carved boss at the crown + ctx.fillStyle = 'rgba(46,38,28,0.55)'; + ctx.beginPath(); + ctx.arc(c, c, size * 0.052, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#c9a94e'; + ctx.beginPath(); + ctx.arc(c, c, size * 0.042, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = 'rgba(255,246,216,0.75)'; + ctx.beginPath(); + ctx.arc(c - size * 0.008, c - size * 0.008, size * 0.02, 0, Math.PI * 2); + ctx.fill(); + noiseOverlay(ctx, size, 0.03, 222); + break; + } + case 'gothic-stone-floor': { + // Worn limestone flags with dark diamond cabochons at the slab corners. + fill(ctx, size, '#a89c86'); + const n = 4; + const cell = size / n; + for (let row = 0; row < n; row++) { + for (let col = 0; col < n; col++) { + const x = col * cell; + const y = row * cell; + const rand = seeded(230 + row * n + col); + const t = (rand() - 0.5) * 26; + ctx.fillStyle = rgb(178 + t, 168 + t, 146 + t); + ctx.fillRect(x + 2, y + 2, cell - 4, cell - 4); + // Footfall polish toward the slab centre + const wear = ctx.createRadialGradient( + x + cell / 2, y + cell / 2, cell * 0.05, + x + cell / 2, y + cell / 2, cell * 0.55 + ); + wear.addColorStop(0, 'rgba(226,218,198,0.35)'); + wear.addColorStop(1, 'rgba(226,218,198,0)'); + ctx.fillStyle = wear; + ctx.fillRect(x + 2, y + 2, cell - 4, cell - 4); + ctx.strokeStyle = 'rgba(58,50,40,0.45)'; + ctx.lineWidth = 3; + ctx.strokeRect(x + 2, y + 2, cell - 4, cell - 4); + } + } + // Dark cabochons where four slabs meet + for (let row = 1; row < n; row++) { + for (let col = 1; col < n; col++) { + const cx = col * cell; + const cy = row * cell; + const r = cell * 0.085; + ctx.fillStyle = '#3c3a3e'; + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx + r, cy); + ctx.lineTo(cx, cy + r); + ctx.lineTo(cx - r, cy); + ctx.closePath(); + ctx.fill(); + } + } + noiseOverlay(ctx, size, 0.05, 231); + break; + } case 'stucco-cream': stuccoSurface(ctx, size, '#f0e8d8', 108); break; @@ -842,6 +1120,11 @@ function normalStrengthFor(kind: SurfaceTextureKind): number { if (kind.includes('stucco') || kind.includes('plaster')) return 2.6; if (kind.includes('panel') || kind.includes('oak') || kind.includes('walnut')) return 2.8; if (kind === 'brick' || kind === 'rough-stone') return 3.2; + // Arcading and rib mouldings are drawn as light/dark bands — lean on the + // derived normals so they read as carved relief, not painted-on lines. + if (kind === 'gothic-ashlar') return 4.2; + if (kind === 'gothic-vault') return 3.6; + if (kind === 'gothic-stone-floor') return 2.4; return 2.5; } @@ -853,6 +1136,9 @@ const ROUGHNESS: Partial> = { 'marble-opus-sectile': 0.2, 'marble-revetment': 0.34, 'coffered-wood': 0.72, + 'gothic-ashlar': 0.84, + 'gothic-vault': 0.9, + 'gothic-stone-floor': 0.56, 'gilded-stucco': 0.22, 'velvet-crimson': 0.92, 'velvet-navy': 0.92, @@ -894,6 +1180,10 @@ const METERS: Partial> = { 'marble-opus-sectile': 5.0, 'marble-revetment': 4.4, 'coffered-wood': 4.2, + // Matches WALL_HEIGHT so one tile = one full storey of arcading. + 'gothic-ashlar': 4.2, + 'gothic-vault': 5.5, + 'gothic-stone-floor': 3.2, flagstone: 3.0, 'mosaic-byzantine': 1.8, 'mosaic-roman': 2.0, @@ -919,6 +1209,9 @@ const NORMAL_SCALE: Partial> = { 'marble-opus-sectile': 0.55, 'marble-revetment': 0.6, 'coffered-wood': 1.0, + 'gothic-ashlar': 1.0, + 'gothic-vault': 0.85, + 'gothic-stone-floor': 0.45, 'velvet-crimson': 0.5, 'velvet-navy': 0.5, 'velvet-emerald': 0.5,