/** * Procedural-vector renderer: layout + segments -> SVG string. * * No external art. The "good look" comes from established cartography tricks: * - thick walls with a soft cast shadow onto the floor (reads as 3-D) * - Dyson-style hatching just outside the exterior walls * - per-theme floor texture drawn in SVG (tiles / flagstones / boards) * - architectural door symbols (leaf + swing arc) * * Pure + deterministic: same seed -> same decoration. Runs in Node and browser. */ import { Rng } from "./rng.mjs"; import { pieceInnerSVG } from "./furniture.mjs"; const THEMES = { modern: { outside: "#23262b", floorBase: "#d9dde2", floorSeam: "#b3bbc4", grid: "#9aa3ad", wall: "#2b2f36", wallTop: "#3c424b", doorFrame: "#3a3f45", doorLeaf: "#1f2329", doorFill: "#5a6470", window: "#8fc4e2", windowFrame: "#3a3f45", label: "#2b2f36", hatch: "#3a3f45", floorStyle: "tile" }, fantasy: { outside: "#2a241c", floorBase: "#bcab8f", floorSeam: "#8d7a5c", grid: "#6f5f47", wall: "#372d20", wallTop: "#4c3f2c", doorFrame: "#6a4a2c", doorLeaf: "#3a2614", doorFill: "#7a5532", window: "#86b8c4", windowFrame: "#4a3a26", label: "#322617", hatch: "#4a3a26", floorStyle: "flagstone" } }; const PX = (v, gs) => +(v * gs).toFixed(1); /** * @param {object} args * @param {object} args.layout generateBuilding() output * @param {object} args.walls buildWalls() output ({segments,...}) * @param {number} [args.gridSize] * @param {string} [args.subtype] "modern" | "fantasy" * @param {boolean} [args.showGrid] * @param {boolean} [args.showLabels] * @param {string|number} [args.seed] * @returns {string} SVG markup */ export function renderSVG(args) { const { layout } = args; const gs = args.gridSize ?? 70; const theme = THEMES[args.subtype] ? THEMES[args.subtype] : THEMES.modern; const showGrid = args.showGrid !== false; const showLabels = args.showLabels !== false; const rng = new Rng(`${args.seed ?? "deco"}::render`); const W = layout.cols * gs; const H = layout.rows * gs; const subtype = THEMES[args.subtype] ? args.subtype : "modern"; const region = layout.region; const cols = layout.cols, rows = layout.rows; const showFurniture = args.showFurniture !== false; const segs = args.walls.segments; const out = []; const floorPath = buildFloorPath(region, cols, rows, gs); out.push(``); // --- defs: shadow filter + clip to floor --------------------------------- out.push(``); out.push( `` + `` + `` ); out.push(``); out.push(``); // --- outside ground ------------------------------------------------------ out.push(``); // --- exterior hatching (drawn under the floor so it tucks beneath walls) -- out.push(``); out.push(exteriorHatching(segs, region, cols, rows, gs, rng)); out.push(``); // --- floor base + texture + furniture (clipped to footprint) ------------- out.push(``); out.push(``); out.push(floorTexture(theme, layout, gs, rng)); if (showGrid) out.push(gridOverlay(layout, gs, theme.grid)); if (showFurniture && args.placements?.length) out.push(drawPlacements(args.placements, subtype)); out.push(``); // --- walls (with cast shadow) -------------------------------------------- const wallW = (gs * 0.17).toFixed(1); out.push(``); out.push(``); for (const s of segs) { if (s.kind !== "wall") continue; out.push(``); } out.push(``); // --- doors + windows (era-aware artwork) --------------------------------- for (const s of segs) { if (s.kind === "door") out.push(doorSymbol(s, gs, theme, subtype)); else if (s.kind === "window") out.push(windowSymbol(s, gs, theme, gs * 0.17, subtype)); } // --- room labels --------------------------------------------------------- if (showLabels) { out.push(``); for (const room of layout.rooms) { if (!room.label) continue; const fontSize = Math.max(8, Math.min(gs * 0.22, room.w * gs * 0.3)); const ly = PX(room.cy, gs) - (room.h * gs) * 0.3; out.push( `${escapeXml(room.label.toUpperCase())}` ); } out.push(``); } out.push(``); return out.join(""); } /** Draw furniture placements (same geometry the locked Tiles use). */ function drawPlacements(placements, subtype) { let out = ``; for (const p of placements) { const inner = pieceInnerSVG(p.kind, subtype, p.wpx, p.hpx); const tx = (p.cx - p.wpx / 2).toFixed(1); const ty = (p.cy - p.hpx / 2).toFixed(1); out += `${inner}`; } return out + ``; } /** Floor path from the region grid: per-row runs of in-room cells -> rects. */ function buildFloorPath(region, cols, rows, gs) { let d = ""; for (let y = 0; y < rows; y += 1) { let x = 0; while (x < cols) { if (region[y][x] < 0) { x += 1; continue; } let run = 0; while (x + run < cols && region[y][x + run] >= 0) run += 1; d += `M${PX(x, gs)},${PX(y, gs)}h${PX(run, gs)}v${PX(1, gs)}h${PX(-run, gs)}Z`; x += run; } } return d; } function regAt(region, x, y, cols, rows) { if (x < 0 || y < 0 || x >= cols || y >= rows) return -1; return region[y][x]; } // --- floor textures -------------------------------------------------------- function floorTexture(theme, layout, gs, rng) { if (theme.floorStyle === "flagstone") return flagstoneTexture(theme, layout, gs, rng); return tileTexture(theme, layout, gs); } function tileTexture(theme, layout, gs) { // Clean square-tile seams across the footprint. const fp = layout.footprint; const lines = []; lines.push(``); for (let x = fp.x; x <= fp.x + fp.w; x += 1) { lines.push(``); } for (let y = fp.y; y <= fp.y + fp.h; y += 1) { lines.push(``); } lines.push(``); return lines.join(""); } function flagstoneTexture(theme, layout, gs, rng) { // Irregular flagstones: per in-room cell, a jittered quad. (Clipped to floor.) const { region, cols, rows } = layout; const j = gs * 0.14; const out = [``]; for (let y = 0; y < rows; y += 1) { for (let x = 0; x < cols; x += 1) { if (region[y][x] < 0) continue; const p = (cx, cy) => `${PX(cx, gs) + (rng.float() - 0.5) * j},${PX(cy, gs) + (rng.float() - 0.5) * j}`; out.push(``); } } out.push(``); return out.join(""); } function gridOverlay(layout, gs, color) { const fp = layout.footprint; const out = [``]; for (let x = fp.x; x <= fp.x + fp.w; x += 1) { out.push(``); } for (let y = fp.y; y <= fp.y + fp.h; y += 1) { out.push(``); } out.push(``); return out.join(""); } // --- exterior hatching ----------------------------------------------------- function exteriorHatching(segs, region, cols, rows, gs, rng) { const out = []; const step = 0.4; // along-wall spacing in cells const len = gs * 0.34; // tick length in px for (const s of segs) { if (s.kind !== "wall") continue; const isV = s.x1 === s.x2; // Detect exterior + outward direction by sampling either side at the midpoint. let nx = 0, ny = 0, exterior = false; if (isV) { const X = s.x1, midY = Math.floor((s.y1 + s.y2) / 2); const left = regAt(region, X - 1, midY, cols, rows); const right = regAt(region, X, midY, cols, rows); if (left === -1 && right >= 0) { nx = -1; exterior = true; } else if (right === -1 && left >= 0) { nx = 1; exterior = true; } } else { const Y = s.y1, midX = Math.floor((s.x1 + s.x2) / 2); const top = regAt(region, midX, Y - 1, cols, rows); const bottom = regAt(region, midX, Y, cols, rows); if (top === -1 && bottom >= 0) { ny = -1; exterior = true; } else if (bottom === -1 && top >= 0) { ny = 1; exterior = true; } } if (!exterior) continue; const length = isV ? (s.y2 - s.y1) : (s.x2 - s.x1); for (let t = step / 2; t < length; t += step) { const bx = isV ? s.x1 : s.x1 + t; const by = isV ? s.y1 + t : s.y1; const px = PX(bx, gs), py = PX(by, gs); // slight slant for a hand-drawn feel const slant = (rng.float() - 0.5) * 0.5; const ex = px + nx * len + (isV ? 0 : slant * len); const ey = py + ny * len + (isV ? slant * len : 0); out.push(``); } } return out.join(""); } // --- door symbol (era-aware artwork) --------------------------------------- function doorSymbol(s, gs, theme, subtype) { const x1 = PX(s.x1, gs), y1 = PX(s.y1, gs); const x2 = PX(s.x2, gs), y2 = PX(s.y2, gs); const L = Math.hypot(x2 - x1, y2 - y1) || 1; const ux = (x2 - x1) / L, uy = (y2 - y1) / L; // along the wall const ppx = -uy, ppy = ux; // wall perpendicular const isV = s.orient === "v"; const insideA = s.inside === "a"; let nx = 0, ny = 0; if (isV) nx = insideA ? -1 : 1; else ny = insideA ? -1 : 1; const wallTh = gs * 0.17; const out = [``]; // Jamb posts (door frame) straddling the wall at both ends. const jamb = wallTh * 1.25; for (const [ex, ey] of [[x1, y1], [x2, y2]]) { out.push( `` ); } // Hinge at end 1; leaf swings ~75° toward the inside. const hx = x1, hy = y1; const ang = (75 * Math.PI) / 180; const sign = (ux * ny - uy * nx) >= 0 ? 1 : -1; const cos = Math.cos(ang * sign), sin = Math.sin(ang * sign); const leafLen = L * 0.94; const dx = ux * cos - uy * sin, dy = ux * sin + uy * cos; // leaf direction const lx = hx + dx * leafLen, ly = hy + dy * leafLen; const ox = hx + ux * leafLen, oy = hy + uy * leafLen; // closed position // Swing arc. out.push( `` ); // Leaf as a filled panel (planked wood / metal slab). const pdx = -dy, pdy = dx; // across the leaf thickness const th = gs * 0.13; const pt = (bx, by, sx, sy) => `${(bx + pdx * sx + dx * sy).toFixed(1)},${(by + pdy * sx + dy * sy).toFixed(1)}`; out.push( `` ); if (subtype === "fantasy") { for (const f of [0.33, 0.66]) { out.push(``); } } else { out.push(``); } // Handle + hinge dot. const handle = pt(hx, hy, 0, leafLen * 0.82).split(","); out.push(``); out.push(``); if (s.locked) { const mx = (hx + ox) / 2, my = (hy + oy) / 2; out.push( `` + `` ); } out.push(``); return out.join(""); } // --- window symbol (era-aware artwork) ------------------------------------- function windowSymbol(s, gs, theme, sill, subtype) { const x1 = PX(s.x1, gs), y1 = PX(s.y1, gs), x2 = PX(s.x2, gs), y2 = PX(s.y2, gs); const L = Math.hypot(x2 - x1, y2 - y1) || 1; const ux = (x2 - x1) / L, uy = (y2 - y1) / L; const px = -uy, py = ux; // perpendicular (sill width) const out = [``]; // Frame band, then glass band. out.push(``); out.push(``); // Highlight streak. out.push(``); const bar = (f) => { const bx = x1 + (x2 - x1) * f, by = y1 + (y2 - y1) * f; out.push(``); }; if (subtype === "fantasy") { bar(0.33); bar(0.66); // leaded panes } else { bar(0.5); // single mullion } out.push(``); return out.join(""); } function escapeXml(str) { return String(str).replace(/[<>&'"]/g, (c) => ( { "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c] )); }