Initial commit: browser OSM city generator

This commit is contained in:
mrleo1nid committed 2026-10-30 18:10:47 +03:00
commit 2d4bad0dc7
32 files changed
+5915

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
// Каталог мебели: каждый предмет — набор простых фигур.
// Локальные оси предмета: x — вдоль ширины, y — в глубину; y < 0 — «спинка» (к стене), y > 0 — лицевая сторона.
import type { Shape } from '../types';
export interface Def {
kind: string;
w: number;
d: number;
shapes: Shape[];
/** Высокий предмет: не ставим перед окнами, закрывает обзор. */
tall?: boolean;
blocksMove?: boolean;
/** Сколько свободного места нужно перед предметом (м). */
front?: number;
tags?: string[];
}
const R = (x: number, y: number, w: number, h: number, s: Shape['s'] = 'base', r?: number): Shape => ({ t: 'rect', x, y, w, h, s, r });
const O = (x: number, y: number, r: number, s: Shape['s'] = 'base'): Shape => ({ t: 'circle', x, y, r, s });
const Ln = (x1: number, y1: number, x2: number, y2: number, s: Shape['s'] = 'line'): Shape => ({ t: 'line', x1, y1, x2, y2, s });
export function bed(double: boolean): Def {
const w = double ? 1.6 : 0.9, d = 2.0;
const shapes: Shape[] = [R(0, 0, w, d, 'wood', 0.05), R(0, 0.05, w - 0.1, d - 0.15, 'soft', 0.06)];
if (double) shapes.push(R(-0.38, -d / 2 + 0.3, 0.62, 0.34, 'base', 0.08), R(0.38, -d / 2 + 0.3, 0.62, 0.34, 'base', 0.08));
else shapes.push(R(0, -d / 2 + 0.3, 0.66, 0.34, 'base', 0.08));
shapes.push(R(0, 0.32, w - 0.08, d * 0.58, 'fabric', 0.05));
return { kind: double ? 'bed_double' : 'bed_single', w, d, shapes, front: 0.5, tags: [`sleep:${double ? 2 : 1}`] };
}
export function bedWithNightstands(): Def {
const b = bed(true);
const w = 1.6 + 2 * 0.5;
const shapes = [...b.shapes];
for (const sx of [-1, 1]) shapes.push(R(sx * (0.8 + 0.25), -0.78, 0.44, 0.4, 'wood', 0.03), O(sx * (0.8 + 0.25), -0.78, 0.1, 'accent'));
return { ...b, kind: 'bed_double', w, shapes };
}
export const hospitalBed = (): Def => ({
kind: 'hospital_bed', w: 1.0, d: 2.1, front: 0.6, tags: ['sleep:1'],
shapes: [R(0, 0, 1.0, 2.1, 'metal', 0.06), R(0, 0.05, 0.86, 1.9, 'soft', 0.05), R(0, -0.75, 0.6, 0.3, 'base', 0.08), Ln(-0.47, -0.5, -0.47, 0.6, 'dark'), Ln(0.47, -0.5, 0.47, 0.6, 'dark')],
});
export const nightstand = (): Def => ({ kind: 'nightstand', w: 0.45, d: 0.4, shapes: [R(0, 0, 0.45, 0.4, 'wood', 0.03), O(0, 0, 0.1, 'accent')] });
export const wardrobe = (w = 1.2): Def => ({
kind: 'wardrobe', w, d: 0.6, tall: true, front: 0.6, tags: ['storage'],
shapes: [R(0, 0, w, 0.6, 'wood', 0.02), Ln(0, -0.28, 0, 0.28), Ln(-w / 2 + 0.05, 0.27, w / 2 - 0.05, 0.27)],
});
export const desk = (w = 1.2): Def => ({
kind: 'desk', w, d: 1.15, front: 0.2, tags: ['work', 'sit:1'],
shapes: [R(0, -0.27, w, 0.6, 'wood', 0.03), R(-w / 2 + 0.25, -0.35, 0.36, 0.25, 'dark', 0.02), O(0, 0.25, 0.24, 'dark')],
});
export const sofa = (w = 2.0): Def => ({
kind: 'sofa', w, d: 0.9, front: 0.4, tags: [`sit:${Math.round(w / 0.65)}`],
shapes: [R(0, 0, w, 0.9, 'fabric', 0.12), R(0, -0.33, w - 0.1, 0.22, 'soft', 0.08), R(-w / 2 + 0.1, 0.05, 0.18, 0.78, 'soft', 0.06), R(w / 2 - 0.1, 0.05, 0.18, 0.78, 'soft', 0.06), Ln(-w / 6, -0.2, -w / 6, 0.4), Ln(w / 6, -0.2, w / 6, 0.4)],
});
export function sofaSet(): Def {
const s = sofa(2.1);
const shapes = s.shapes.map((sh) => shift(sh, 0, -0.55));
shapes.push(R(0, 0.6, 1.05, 0.55, 'wood', 0.06), R(0, 0.3, 2.4, 1.5, 'accent', 0.1));
// ковёр под набором — первым, чтобы был снизу
shapes.unshift(shapes.pop()!);
return { kind: 'sofa_set', w: 2.4, d: 2.0, shapes, front: 0.3, tags: s.tags };
}
export const armchair = (): Def => ({
kind: 'armchair', w: 0.85, d: 0.85, tags: ['sit:1'],
shapes: [R(0, 0, 0.85, 0.85, 'fabric', 0.15), R(0, -0.3, 0.75, 0.2, 'soft', 0.08), R(0, 0.08, 0.5, 0.5, 'soft', 0.1)],
});
export const tvStand = (): Def => ({ kind: 'tv', w: 1.6, d: 0.45, front: 1.2, shapes: [R(0, 0, 1.6, 0.45, 'wood', 0.03), R(0, -0.08, 1.2, 0.07, 'dark')] });
export const bookshelf = (w = 1.0): Def => ({ kind: 'bookshelf', w, d: 0.35, tall: true, front: 0.5, shapes: [R(0, 0, w, 0.35, 'wood', 0.02), Ln(-w / 2 + 0.05, 0, w / 2 - 0.05, 0)] });
export const plant = (): Def => ({ kind: 'plant', w: 0.5, d: 0.5, blocksMove: true, shapes: [O(0, 0, 0.22, 'base'), O(0, 0, 0.26, 'green')] });
export function diningTable(seats: 4 | 6): Def {
const tw = seats === 4 ? 1.2 : 1.8, th = 0.85;
const shapes: Shape[] = [];
const n = seats / 2;
for (let i = 0; i < n; i++) {
const x = -tw / 2 + (tw / n) * (i + 0.5);
shapes.push(R(x, -th / 2 - 0.3, 0.42, 0.42, 'wood', 0.06), R(x, th / 2 + 0.3, 0.42, 0.42, 'wood', 0.06));
}
shapes.push(R(0, 0, tw, th, 'wood', 0.04));
return { kind: 'dining_table', w: tw + 0.2, d: th + 1.2, shapes, tags: [`sit:${seats}`] };
}
export function kitchenLine(L: number): Def {
const shapes: Shape[] = [R(0, 0, L, 0.62, 'base', 0.02)];
// плита, мойка, разделочные зоны
const stoveX = -L / 2 + Math.min(L * 0.3, 0.9);
shapes.push(R(stoveX, 0, 0.6, 0.56, 'dark', 0.03));
for (const [dx, dy] of [[-0.14, -0.13], [0.14, -0.13], [-0.14, 0.13], [0.14, 0.13]]) shapes.push(O(stoveX + dx, dy, 0.09, 'metal'));
const sinkX = L / 2 - Math.min(L * 0.3, 0.75);
shapes.push(R(sinkX, 0.02, 0.6, 0.44, 'metal', 0.06), O(sinkX + 0.12, 0.02, 0.05, 'dark'));
return { kind: 'kitchen', w: L, d: 0.62, front: 0.9, shapes, tags: ['cook'] };
}
export const fridge = (): Def => ({ kind: 'fridge', w: 0.62, d: 0.66, tall: true, front: 0.7, shapes: [R(0, 0, 0.62, 0.66, 'base', 0.04), Ln(-0.25, 0.3, 0.25, 0.3, 'dark')], tags: ['food'] });
export const toilet = (): Def => ({ kind: 'toilet', w: 0.45, d: 0.72, front: 0.5, shapes: [R(0, -0.26, 0.45, 0.2, 'base', 0.04), O(0, 0.08, 0.2, 'base'), O(0, 0.1, 0.12, 'water')] });
export const basin = (): Def => ({ kind: 'basin', w: 0.55, d: 0.45, front: 0.5, shapes: [R(0, 0, 0.55, 0.45, 'base', 0.08), O(0, 0.03, 0.14, 'water')] });
export const bathtub = (): Def => ({ kind: 'bathtub', w: 1.7, d: 0.76, shapes: [R(0, 0, 1.7, 0.76, 'base', 0.12), R(0.05, 0, 1.45, 0.56, 'water', 0.2), O(-0.6, 0, 0.04, 'dark')] });
export const shower = (): Def => ({ kind: 'shower', w: 0.9, d: 0.9, shapes: [R(0, 0, 0.9, 0.9, 'base', 0.02), R(0, 0, 0.8, 0.8, 'water', 0.02), O(0, 0, 0.04, 'dark'), Ln(-0.45, 0.45, 0.45, 0.45, 'glass')] });
export const washer = (): Def => ({ kind: 'washer', w: 0.6, d: 0.6, front: 0.5, shapes: [R(0, 0, 0.6, 0.6, 'base', 0.04), O(0, 0.04, 0.2, 'dark'), O(0, 0.04, 0.14, 'glass')] });
export const coatRack = (): Def => ({ kind: 'coat_rack', w: 0.9, d: 0.35, shapes: [R(0, 0, 0.9, 0.35, 'wood', 0.03), O(-0.25, 0.02, 0.09, 'fabric'), O(0.05, 0.02, 0.09, 'fabric'), O(0.3, 0.02, 0.09, 'accent')] });
export const shoeCabinet = (): Def => ({ kind: 'shoe_cabinet', w: 0.8, d: 0.35, shapes: [R(0, 0, 0.8, 0.35, 'wood', 0.02)] });
export const shelf = (w = 1.2): Def => ({ kind: 'shelf', w, d: 0.5, tall: true, front: 0.7, tags: ['storage'], shapes: [R(0, 0, w, 0.5, 'metal', 0.02), Ln(-w / 2, 0, w / 2, 0, 'dark'), R(-w / 4, 0, w / 3, 0.36, 'wood', 0.02), R(w / 4, 0, w / 4, 0.3, 'accent', 0.02)] });
export const crate = (): Def => ({ kind: 'crate', w: 0.8, d: 0.8, shapes: [R(0, 0, 0.8, 0.8, 'wood', 0.03), Ln(-0.36, -0.36, 0.36, 0.36), Ln(-0.36, 0.36, 0.36, -0.36)], tags: ['container'] });
export const boiler = (): Def => ({ kind: 'boiler', w: 0.6, d: 0.6, tall: true, shapes: [O(0, 0, 0.29, 'metal'), O(0, 0, 0.1, 'dark')] });
export function gondola(L: number): Def {
const shapes: Shape[] = [R(0, 0, L, 1.0, 'base', 0.03), Ln(-L / 2 + 0.05, 0, L / 2 - 0.05, 0, 'dark')];
for (let x = -L / 2 + 0.5; x < L / 2 - 0.2; x += 0.9) shapes.push(R(x + 0.2, -0.25, 0.6, 0.3, 'accent', 0.02), R(x + 0.25, 0.25, 0.5, 0.3, 'fabric', 0.02));
return { kind: 'gondola', w: L, d: 1.0, tall: true, front: 1.2, tags: ['goods'], shapes };
}
export function wallShelf(L: number): Def {
const shapes: Shape[] = [R(0, 0, L, 0.5, 'base', 0.02)];
for (let x = -L / 2 + 0.45; x < L / 2 - 0.2; x += 0.9) shapes.push(R(x, 0.02, 0.7, 0.32, 'accent', 0.02));
return { kind: 'wall_shelf', w: L, d: 0.5, tall: true, front: 1.0, tags: ['goods'], shapes };
}
export const fridgeDisplay = (L = 2.4): Def => ({ kind: 'fridge_display', w: L, d: 0.85, tall: true, front: 1.2, tags: ['goods'], shapes: [R(0, 0, L, 0.85, 'metal', 0.03), R(0, 0.15, L - 0.1, 0.45, 'glass', 0.02)] });
export const checkout = (): Def => ({ kind: 'checkout', w: 0.9, d: 2.0, front: 0, tags: ['work', 'trade'], shapes: [R(0, 0, 0.9, 2.0, 'base', 0.04), R(0, 0.35, 0.5, 1.0, 'dark', 0.03), R(0.15, -0.6, 0.35, 0.3, 'metal', 0.02)] });
export const counter = (L = 1.8): Def => ({ kind: 'counter', w: L, d: 0.65, front: 1.0, tags: ['work', 'trade'], shapes: [R(0, 0, L, 0.65, 'wood', 0.04), R(-L / 2 + 0.35, 0, 0.35, 0.3, 'dark', 0.02)] });
export const clothesRack = (): Def => ({ kind: 'rack', w: 1.4, d: 0.6, front: 0.8, tags: ['goods'], shapes: [Ln(-0.7, 0, 0.7, 0, 'metal'), O(-0.45, 0, 0.17, 'fabric'), O(-0.15, 0, 0.17, 'accent'), O(0.15, 0, 0.17, 'fabric'), O(0.45, 0, 0.17, 'soft')] });
export function cafeTable(): Def {
return {
kind: 'cafe_table', w: 1.5, d: 1.5, tags: ['sit:4'],
shapes: [O(0, -0.55, 0.2, 'wood'), O(0, 0.55, 0.2, 'wood'), O(-0.55, 0, 0.2, 'wood'), O(0.55, 0, 0.2, 'wood'), O(0, 0, 0.38, 'base')],
};
}
export const proStove = (): Def => ({ kind: 'pro_stove', w: 1.2, d: 0.8, front: 1.0, tags: ['cook'], shapes: [R(0, 0, 1.2, 0.8, 'metal', 0.03), O(-0.3, -0.15, 0.13, 'dark'), O(0.3, -0.15, 0.13, 'dark'), O(-0.3, 0.18, 0.13, 'dark'), O(0.3, 0.18, 0.13, 'dark')] });
export const prepTable = (L = 1.6): Def => ({ kind: 'prep_table', w: L, d: 0.7, front: 0.9, tags: ['cook'], shapes: [R(0, 0, L, 0.7, 'metal', 0.02), Ln(-L / 2 + 0.05, 0.3, L / 2 - 0.05, 0.3, 'dark')] });
export const machine = (): Def => ({ kind: 'machine', w: 2.4, d: 1.4, front: 0.8, tags: ['work'], shapes: [R(0, 0, 2.4, 1.4, 'metal', 0.05), R(-0.4, 0, 1.1, 0.8, 'dark', 0.04), O(0.75, 0, 0.3, 'accent'), Ln(-1.2, 0.55, 1.2, 0.55, 'accent')] });
export const workbench = (L = 2.0): Def => ({ kind: 'workbench', w: L, d: 0.75, front: 0.8, tags: ['work'], shapes: [R(0, 0, L, 0.75, 'wood', 0.02), R(-L / 2 + 0.3, 0, 0.3, 0.3, 'metal'), R(L / 4, -0.15, 0.6, 0.25, 'accent', 0.02)] });
export function palletRack(L: number): Def {
const shapes: Shape[] = [R(0, 0, L, 1.1, 'base', 0.01)];
for (let x = -L / 2 + 0.7; x < L / 2 - 0.3; x += 1.4) shapes.push(R(x, 0, 1.15, 0.95, 'wood', 0.02), Ln(x - 0.5, -0.4, x + 0.5, 0.4, 'dark'));
for (let x = -L / 2; x <= L / 2 + 1e-6; x += 2.8) shapes.push(R(Math.min(x, L / 2 - 0.05), 0, 0.1, 1.1, 'accent'));
return { kind: 'pallet_rack', w: L, d: 1.1, tall: true, front: 1.5, tags: ['storage'], shapes };
}
export const pallet = (): Def => ({ kind: 'pallet', w: 1.2, d: 1.0, tags: ['container'], shapes: [R(0, 0, 1.2, 1.0, 'wood', 0.02), R(0, 0, 1.0, 0.8, 'accent', 0.03)] });
export function studentDesk(): Def {
return {
kind: 'student_desk', w: 1.3, d: 1.05, tags: ['sit:2'],
shapes: [R(0, -0.2, 1.3, 0.55, 'wood', 0.03), R(-0.32, 0.3, 0.4, 0.38, 'dark', 0.06), R(0.32, 0.3, 0.4, 0.38, 'dark', 0.06)],
};
}
export const teacherDesk = (): Def => ({ kind: 'teacher_desk', w: 1.5, d: 1.2, tags: ['work', 'sit:1'], shapes: [R(0, 0.2, 1.5, 0.7, 'wood', 0.03), O(0, -0.35, 0.24, 'dark')] });
export const board = (L = 3.0): Def => ({ kind: 'board', w: L, d: 0.12, blocksMove: false, front: 1.5, shapes: [R(0, 0, L, 0.1, 'dark')] });
export const pew = (L: number): Def => ({ kind: 'pew', w: L, d: 0.55, tags: [`sit:${Math.floor(L / 0.55)}`], shapes: [R(0, 0.06, L, 0.42, 'wood', 0.04), R(0, -0.2, L, 0.1, 'dark')] });
export const altar = (): Def => ({ kind: 'altar', w: 2.0, d: 0.9, front: 1.5, shapes: [R(0, 0, 2.0, 0.9, 'base', 0.03), R(0, 0, 2.1, 0.3, 'accent'), O(-0.7, 0, 0.06, 'accent'), O(0.7, 0, 0.06, 'accent')] });
export function officeCluster(): Def {
const shapes: Shape[] = [];
for (const sx of [-0.7, 0.7]) {
shapes.push(R(sx, -0.35, 1.38, 0.7, 'wood', 0.02), R(sx, 0.35, 1.38, 0.7, 'wood', 0.02));
shapes.push(R(sx - 0.3, -0.5, 0.4, 0.18, 'dark'), R(sx - 0.3, 0.5, 0.4, 0.18, 'dark'));
shapes.push(O(sx, -1.0, 0.24, 'dark'), O(sx, 1.0, 0.24, 'dark'));
}
return { kind: 'desk_cluster', w: 2.8, d: 2.6, tags: ['work', 'sit:4'], shapes };
}
export function meetingTable(L: number): Def {
const shapes: Shape[] = [];
const n = Math.max(2, Math.floor((L - 0.4) / 0.7));
for (let i = 0; i < n; i++) {
const x = -L / 2 + 0.2 + ((L - 0.4) / n) * (i + 0.5);
shapes.push(O(x, -0.85, 0.23, 'dark'), O(x, 0.85, 0.23, 'dark'));
}
shapes.push(R(0, 0, L, 1.1, 'wood', 0.2));
return { kind: 'meeting_table', w: L + 0.2, d: 2.2, tags: [`sit:${n * 2}`, 'work'], shapes };
}
export const filing = (): Def => ({ kind: 'filing_cabinet', w: 0.5, d: 0.6, tall: true, front: 0.6, shapes: [R(0, 0, 0.5, 0.6, 'metal', 0.02), Ln(-0.2, 0.0, 0.2, 0.0, 'dark')] });
export const mailboxes = (): Def => ({ kind: 'mailboxes', w: 1.2, d: 0.3, shapes: [R(0, 0, 1.2, 0.3, 'metal', 0.02), Ln(-0.3, -0.14, -0.3, 0.14, 'dark'), Ln(0, -0.14, 0, 0.14, 'dark'), Ln(0.3, -0.14, 0.3, 0.14, 'dark')] });
export function carDef(): Def {
return {
kind: 'car', w: 1.8, d: 4.4, tags: ['vehicle'],
shapes: [R(0, 0, 1.8, 4.4, 'car', 0.45), R(0, -0.75, 1.5, 0.55, 'glass', 0.15), R(0, 1.25, 1.45, 0.4, 'glass', 0.12), R(0, 0.25, 1.5, 1.4, 'dark', 0.2)],
};
}
export function shift(s: Shape, dx: number, dy: number): Shape {
if (s.t === 'line') return { ...s, x1: s.x1 + dx, x2: s.x2 + dx, y1: s.y1 + dy, y2: s.y2 + dy };
return { ...s, x: s.x + dx, y: s.y + dy };
}
+412
View File
@@ -0,0 +1,412 @@
// Уровень города: зоны, дороги, контуры зданий, уличные объекты.
// Интерьеры генерируются отдельно (interior.ts) — в воркерах.
import type { OsmElement, BBox } from '../osm/overpass';
import type { Area, AreaKind, Building, Prop, Ring, Road, RoadKind, Vec, World } from '../types';
import { Projector } from './project';
import {
bboxOf, centroid, cleanRing, dist, distToSegment, minAreaRectAngle,
pointInRing, pointInRings, ringArea, rotate, signedArea,
} from '../util/geom';
import { Rng, hashString } from '../util/rng';
import { SpatialGrid } from '../util/spatial';
import { classify, clampLevels, defaultLevels, parseLevels } from './classify';
import { carShapes, CAR_COLORS, treeProp } from './props';
export interface InteriorJob {
building: Building;
/** Ближайшие к зданию участки дорог: [a, b, вес]. Вход ставится к дороге. */
roadSegs: [Vec, Vec, number][];
}
const LANDUSE: Record<string, AreaKind> = {
residential: 'residential', commercial: 'commercial', retail: 'retail', industrial: 'industrial',
farmland: 'farmland', farm: 'farmland', farmyard: 'farmyard', grass: 'grass', village_green: 'grass',
recreation_ground: 'grass', meadow: 'meadow', forest: 'forest', orchard: 'orchard', vineyard: 'vineyard',
cemetery: 'cemetery', construction: 'construction', brownfield: 'construction', greenfield: 'meadow',
railway: 'railway', allotments: 'garden', basin: 'water', reservoir: 'water', plant_nursery: 'garden',
flowerbed: 'garden', garages: 'industrial', religious: 'grass', education: 'school',
};
const NATURAL: Record<string, AreaKind> = {
water: 'water', wood: 'forest', scrub: 'scrub', heath: 'scrub', grassland: 'meadow', wetland: 'wetland',
beach: 'beach', sand: 'sand', bare_rock: 'sand', scree: 'sand',
};
const LEISURE: Record<string, AreaKind> = {
park: 'park', garden: 'garden', pitch: 'pitch', track: 'pitch', playground: 'playground',
golf_course: 'grass', swimming_pool: 'water', dog_park: 'grass', common: 'grass',
};
const AMENITY_AREA: Record<string, AreaKind> = {
parking: 'parking', school: 'school', kindergarten: 'school', college: 'school', university: 'school',
hospital: 'hospital', grave_yard: 'cemetery', marketplace: 'pedestrian', fuel: 'pedestrian',
};
const AREA_TIER: Record<AreaKind, number> = {
residential: 0, commercial: 0, industrial: 0, retail: 0, farmland: 0, railway: 0, school: 0, hospital: 0, construction: 0,
farmyard: 1, forest: 1, meadow: 1, grass: 1, park: 1, scrub: 1, orchard: 1, vineyard: 1, cemetery: 1, garden: 1,
wetland: 1, beach: 1, sand: 1,
pitch: 2, playground: 2, parking: 2, pedestrian: 2, water: 3,
};
const ROAD_WIDTH: Record<string, number> = {
motorway: 11, trunk: 10, primary: 9, secondary: 8, tertiary: 7, residential: 6, unclassified: 5.5,
living_street: 5, service: 3.6, pedestrian: 5, track: 3, footway: 1.8, cycleway: 2, path: 1.2, steps: 2,
bridleway: 1.5, road: 5,
};
const SIDEWALK: Record<string, number> = { primary: 2.2, secondary: 2, tertiary: 1.8, residential: 1.5, living_street: 0, trunk: 0 };
function areaKindOf(t: Record<string, string>): AreaKind | null {
if (t.building) return null;
if (t.landuse && LANDUSE[t.landuse]) return LANDUSE[t.landuse];
if (t.natural && NATURAL[t.natural]) return NATURAL[t.natural];
if (t.waterway === 'riverbank' || t.waterway === 'dock') return 'water';
if (t.leisure && LEISURE[t.leisure]) return LEISURE[t.leisure];
if (t.amenity && AMENITY_AREA[t.amenity]) return AMENITY_AREA[t.amenity];
if (t.place === 'square' || t['area:highway'] || (t.highway === 'pedestrian' && t.area === 'yes')) return 'pedestrian';
return null;
}
/** Сборка колец мультиполигона из кусков путей. */
function assembleRings(parts: Vec[][]): Vec[][] {
const pool = parts.filter((p) => p.length >= 2).map((p) => p.slice());
const rings: Vec[][] = [];
const same = (a: Vec, b: Vec) => Math.abs(a[0] - b[0]) < 1e-6 && Math.abs(a[1] - b[1]) < 1e-6;
while (pool.length) {
let cur = pool.shift()!;
let grew = true;
while (!same(cur[0], cur[cur.length - 1]) && grew) {
grew = false;
for (let i = 0; i < pool.length; i++) {
const p = pool[i];
const end = cur[cur.length - 1];
if (same(end, p[0])) cur = cur.concat(p.slice(1));
else if (same(end, p[p.length - 1])) cur = cur.concat(p.slice(0, -1).reverse());
else if (same(cur[0], p[p.length - 1])) cur = p.slice(0, -1).concat(cur);
else if (same(cur[0], p[0])) cur = p.slice(1).reverse().concat(cur);
else continue;
pool.splice(i, 1);
grew = true;
break;
}
}
if (cur.length >= 4) rings.push(cur);
}
return rings;
}
interface Poly {
outer: Ring;
holes: Ring[];
}
function polysOfElement(el: OsmElement, proj: Projector): Poly[] {
if (el.type === 'way' && el.geometry) {
const g = el.geometry;
if (g.length < 4) return [];
const a = g[0], b = g[g.length - 1];
if (a.lat !== b.lat || a.lon !== b.lon) return [];
return [{ outer: g.map((p) => proj.toXY(p.lat, p.lon)), holes: [] }];
}
if (el.type === 'relation' && el.members) {
const outers = assembleRings(el.members.filter((m) => m.role !== 'inner' && m.geometry).map((m) => m.geometry!.map((p) => proj.toXY(p.lat, p.lon))));
const inners = assembleRings(el.members.filter((m) => m.role === 'inner' && m.geometry).map((m) => m.geometry!.map((p) => proj.toXY(p.lat, p.lon))));
return outers.map((o) => ({ outer: o, holes: inners.filter((h) => pointInRing(h[0], o)) }));
}
return [];
}
/** Ориентируем кольца единообразно: внешнее — по часовой (в осях y-вниз), дыры — против. */
function orient(r: Ring, outer: boolean): Ring {
const a = signedArea(r);
return (a > 0) === outer ? r : r.slice().reverse();
}
export function buildCity(elements: OsmElement[], bbox: BBox, name: string, seed: number): { world: World; jobs: InteriorJob[] } {
const lat0 = (bbox[0] + bbox[2]) / 2, lon0 = (bbox[1] + bbox[3]) / 2;
const proj = new Projector(lat0, lon0);
const p0 = proj.toXY(bbox[2], bbox[1]);
const p1 = proj.toXY(bbox[0], bbox[3]);
const bounds = { minX: p0[0], minY: p0[1], maxX: p1[0], maxY: p1[1] };
const inBounds = (p: Vec, m = 0) => p[0] >= bounds.minX - m && p[0] <= bounds.maxX + m && p[1] >= bounds.minY - m && p[1] <= bounds.maxY + m;
const rng = new Rng(seed);
const areas: Area[] = [];
const roads: Road[] = [];
const props: Prop[] = [];
const pois: { p: Vec; tags: Record<string, string> }[] = [];
const buildingEls: { el: OsmElement; poly: Poly }[] = [];
for (const el of elements) {
const t = el.tags ?? {};
if (el.type === 'node') {
if (el.lat == null || el.lon == null) continue;
const p = proj.toXY(el.lat, el.lon);
if (!inBounds(p, 5)) continue;
if (t.natural === 'tree') props.push(treeProp(`t${el.id}`, p, rng, t));
else if (t.highway === 'street_lamp') props.push({ id: `l${el.id}`, kind: 'lamp', pos: p, rot: 0, w: 0.4, d: 0.4, shapes: [{ t: 'circle', x: 0, y: 0, r: 0.22, s: 'metal' }], blocksMove: true, blocksSight: false });
else if (t.amenity === 'bench') props.push({ id: `b${el.id}`, kind: 'bench', pos: p, rot: rng.range(0, Math.PI), w: 1.8, d: 0.6, shapes: [{ t: 'rect', x: 0, y: 0, w: 1.8, h: 0.55, s: 'wood' }, { t: 'rect', x: 0, y: -0.22, w: 1.8, h: 0.12, s: 'dark' }], blocksMove: true, blocksSight: false, tags: ['sit'] });
else if (t.amenity === 'fountain') props.push({ id: `f${el.id}`, kind: 'fountain', pos: p, rot: 0, w: 4, d: 4, shapes: [{ t: 'circle', x: 0, y: 0, r: 2, s: 'base' }, { t: 'circle', x: 0, y: 0, r: 1.7, s: 'water' }], blocksMove: true, blocksSight: false });
if (t.shop || t.amenity || t.office || t.craft || t.tourism || t.healthcare) pois.push({ p, tags: t });
continue;
}
if (t.building && t.building !== 'no' && t.building !== 'roof' && !t['building:part']) {
for (const poly of polysOfElement(el, proj)) buildingEls.push({ el, poly });
continue;
}
// Дороги, рельсы, водотоки
const hw = t.highway, rw = t.railway, ww = t.waterway;
const isLinear = el.type === 'way' && el.geometry && (hw || rw || ww) && !(t.area === 'yes') && !(ww === 'riverbank');
if (isLinear && ROAD_WIDTH[hw] !== undefined || (isLinear && (rw || ww))) {
const pts = el.geometry!.map((g) => proj.toXY(g.lat, g.lon));
let kind: RoadKind | null = null;
let width = 0, sidewalk = 0;
if (hw && ROAD_WIDTH[hw] !== undefined) {
kind = (['motorway', 'trunk', 'primary', 'secondary', 'tertiary', 'residential', 'unclassified', 'service', 'living_street', 'pedestrian', 'track', 'footway', 'cycleway', 'path', 'steps'] as const).find((k) => hw.startsWith(k)) ?? (hw === 'bridleway' ? 'path' : 'unclassified');
width = ROAD_WIDTH[hw.replace('_link', '')] ?? 5;
if (hw.endsWith('_link')) width *= 0.75;
if (hw === 'service' && (t.service === 'parking_aisle')) width = 5;
if (hw === 'service' && (t.service === 'driveway')) width = 3;
const lanes = parseInt(t.lanes);
if (isFinite(lanes) && lanes > 0 && ROAD_WIDTH[hw] >= 5) width = Math.max(width * 0.6, lanes * 3.2);
const w = parseFloat(t.width);
if (isFinite(w) && w > 0.5 && w < 60) width = w;
sidewalk = SIDEWALK[hw] ?? 0;
if (t.sidewalk === 'no' || t.sidewalk === 'none' || t.sidewalk === 'separate') sidewalk = 0;
else if (t.sidewalk && sidewalk === 0 && width >= 5) sidewalk = 1.5;
} else if (rw) {
if (rw === 'platform') continue;
kind = rw === 'tram' ? 'tram' : 'rail';
width = 1.6;
} else if (ww) {
kind = ww === 'river' ? 'river' : ww === 'canal' ? 'canal' : ww === 'stream' ? 'stream' : 'ditch';
width = { river: 14, canal: 8, stream: 2.5, ditch: 1.2 }[kind as 'river'];
const w = parseFloat(t.width);
if (isFinite(w) && w > 0.3 && w < 300) width = w;
if (t.tunnel === 'culvert') continue;
}
if (!kind) continue;
roads.push({
id: `r${el.id}`, osmId: `way/${el.id}`, kind, pts, width, sidewalk,
lanes: parseInt(t.lanes) || 0, oneway: t.oneway === 'yes',
layer: parseInt(t.layer) || 0, bridge: !!t.bridge && t.bridge !== 'no', tunnel: !!t.tunnel && t.tunnel !== 'no',
name: t.name,
});
continue;
}
const ak = areaKindOf(t);
if (ak) {
for (const poly of polysOfElement(el, proj)) {
const bb = bboxOf(poly.outer);
if (bb[2] < bounds.minX || bb[0] > bounds.maxX || bb[3] < bounds.minY || bb[1] > bounds.maxY) continue;
areas.push({ id: `a${el.type[0]}${el.id}_${areas.length}`, osmId: `${el.type}/${el.id}`, kind: ak, rings: [orient(cleanRing(poly.outer, 0.01), true), ...poly.holes.map((h) => orient(h, false))], name: t.name });
}
}
if (t.natural === 'tree_row' && el.type === 'way' && el.geometry) {
const pts = el.geometry.map((g) => proj.toXY(g.lat, g.lon));
let acc = 0;
for (let i = 1; i < pts.length; i++) {
const L = dist(pts[i - 1], pts[i]);
for (let s = acc; s < L; s += 8) {
const q: Vec = [pts[i - 1][0] + ((pts[i][0] - pts[i - 1][0]) * s) / L, pts[i - 1][1] + ((pts[i][1] - pts[i - 1][1]) * s) / L];
if (inBounds(q)) props.push(treeProp(`tr${el.id}_${props.length}`, q, rng, {}));
}
acc = (acc - L) % 8;
if (acc < 0) acc += 8;
}
}
}
// Порядок отрисовки зон: крупные подложки → зелень → площадки → вода
areas.sort((a, b) => AREA_TIER[a.kind] - AREA_TIER[b.kind] || ringArea(b.rings[0]) - ringArea(a.rings[0]));
// ---- Индексы для запросов
const landGrid = new SpatialGrid<Area>(100);
for (const a of areas) if (AREA_TIER[a.kind] <= 1) landGrid.insert(a, bboxOf(a.rings[0]));
type Seg = { a: Vec; b: Vec; road: Road };
const segGrid = new SpatialGrid<Seg>(40);
for (const r of roads) {
if (r.tunnel) continue;
for (let i = 1; i < r.pts.length; i++) {
const a = r.pts[i - 1], b = r.pts[i];
const m = r.width / 2 + r.sidewalk + 1;
segGrid.insert({ a, b, road: r }, [Math.min(a[0], b[0]) - m, Math.min(a[1], b[1]) - m, Math.max(a[0], b[0]) + m, Math.max(a[1], b[1]) + m]);
}
}
const poiGrid = new SpatialGrid<{ p: Vec; tags: Record<string, string> }>(30);
for (const p of pois) poiGrid.insert(p, [p.p[0], p.p[1], p.p[0], p.p[1]]);
// ---- Здания
const buildings: Building[] = [];
const bGrid = new SpatialGrid<Building>(40);
for (const { el, poly } of buildingEls) {
let outer = cleanRing(poly.outer, 0.08);
if (outer.length < 3) continue;
const area = ringArea(outer) - poly.holes.reduce((s, h) => s + ringArea(h), 0);
if (area < 6 || area > 40000) continue;
const c = centroid(outer);
if (!inBounds(c)) continue;
outer = orient(outer, true);
const t = el.tags ?? {};
const id = `b${el.type[0]}${el.id}`;
const b: Building = {
id: buildings.some((x) => x.id === id) ? `${id}_${buildings.length}` : id,
osmId: `${el.type}/${el.id}`,
kind: 'house', footprint: outer, holes: poly.holes.map((h) => orient(cleanRing(h, 0.08), false)).filter((h) => h.length >= 3),
area, levels: 1, levelsSource: 'estimate', frame: { origin: [0, 0], angle: minAreaRectAngle(outer) },
floors: [], units: [], beds: 0, seed: hashString(`${el.type}/${el.id}`) ^ seed,
name: t.name,
address: t['addr:street'] ? `${t['addr:street']} ${t['addr:housenumber'] ?? ''}`.trim() : t['addr:housenumber'],
};
(b as any)._tags = t;
buildings.push(b);
bGrid.insert(b, bboxOf(outer));
}
for (const b of buildings) {
const t = (b as any)._tags as Record<string, string>;
const brng = new Rng(b.seed);
const bb = bboxOf(b.footprint);
const inside = poiGrid.query(bb).filter((p) => pointInRing(p.p, b.footprint)).map((p) => p.tags);
const c = centroid(b.footprint);
let landuse: string | undefined;
for (const a of landGrid.query([c[0], c[1], c[0], c[1]])) {
if (AREA_TIER[a.kind] === 0 && pointInRings(c, a.rings)) landuse = a.kind;
if (a.kind === 'farmyard' && pointInRings(c, a.rings)) landuse = 'farmyard';
}
const near = bGrid.query([bb[0] - 15, bb[1] - 15, bb[2] + 15, bb[3] + 15]);
const nearBigger = near.some((o) => o !== b && o.area > b.area * 2.5 && o.area > 60);
const cls = classify({ tags: t, pois: inside, area: b.area, landuse, nearBigger }, brng);
b.kind = cls.kind;
b.use = cls.use;
if (!b.name) b.name = inside.find((p) => p.name)?.name;
const lv = parseLevels(t);
if (lv) {
b.levels = lv.levels;
b.levelsSource = lv.source;
}
}
// Этажность без тегов: медиана соседей с тегами, иначе по виду
const tagged = buildings.filter((b) => b.levelsSource !== 'estimate');
const tGrid = new SpatialGrid<Building>(100);
for (const b of tagged) tGrid.insert(b, bboxOf(b.footprint));
for (const b of buildings) {
if (b.levelsSource !== 'estimate') continue;
const brng = new Rng(b.seed ^ 0x51ed);
const c = centroid(b.footprint);
const ns = tGrid.query([c[0] - 120, c[1] - 120, c[0] + 120, c[1] + 120])
.filter((o) => o.kind === b.kind || (o.kind === 'apartments' && b.kind === 'mixed') || (o.kind === 'mixed' && b.kind === 'apartments'))
.map((o) => o.levels).sort((x, y) => x - y);
b.levels = ns.length >= 3 ? clampLevels(b.kind, ns[ns.length >> 1]) : defaultLevels(b.kind, b.area, brng);
if (b.area < 45) b.levels = Math.min(b.levels, 1);
}
for (const b of buildings) delete (b as any)._tags;
// ---- Уличные объекты: деревья в лесах/парках, машины на парковках
const blockedByBuilding = (p: Vec, m: number) =>
bGrid.query([p[0] - m, p[1] - m, p[0] + m, p[1] + m]).some((b) => pointInRing(p, b.footprint) || distToRing(p, b.footprint) < m);
const nearRoad = (p: Vec, m: number) =>
segGrid.query([p[0] - m, p[1] - m, p[0] + m, p[1] + m]).some((s) => distToSegment(p, s.a, s.b) < s.road.width / 2 + s.road.sidewalk + m);
let treeBudget = 40000;
for (const a of areas) {
const spacing = a.kind === 'forest' ? 6 : a.kind === 'park' ? 14 : a.kind === 'orchard' ? 7 : a.kind === 'cemetery' ? 16 : a.kind === 'scrub' ? 8 : 0;
if (!spacing) continue;
const bb = bboxOf(a.rings[0]);
const x0 = Math.max(bb[0], bounds.minX), x1 = Math.min(bb[2], bounds.maxX);
const y0 = Math.max(bb[1], bounds.minY), y1 = Math.min(bb[3], bounds.maxY);
const ar = new Rng(hashString(a.id) ^ seed);
for (let y = y0; y < y1 && treeBudget > 0; y += spacing)
for (let x = x0; x < x1 && treeBudget > 0; x += spacing) {
const p: Vec = [x + ar.range(0, spacing * 0.8), y + ar.range(0, spacing * 0.8)];
if (a.kind === 'park' && ar.chance(0.35)) continue;
if (!pointInRings(p, a.rings)) continue;
if (blockedByBuilding(p, 2) || nearRoad(p, 1.5)) continue;
props.push(treeProp(`ta${a.id}_${treeBudget}`, p, ar, a.kind === 'scrub' ? { scrub: '1' } : a.kind === 'orchard' ? { orchard: '1' } : {}));
treeBudget--;
}
}
// Парковки: ряды мест 2.5×5 м с проездами 6 м
for (const a of areas) {
if (a.kind !== 'parking') continue;
const outer = a.rings[0];
if (ringArea(outer) < 60) continue;
const ang = minAreaRectAngle(outer);
const local = outer.map((p) => rotate(p, -ang));
const lb = bboxOf(local);
const pr = new Rng(hashString(a.id) ^ seed);
const fill = pr.range(0.45, 0.85);
// полоса: ряд машин 5 м, проезд 6 м, ряд 5 м, ...
let y = lb[1] + 0.5;
let row = 0;
while (y + 5 <= lb[3]) {
for (let x = lb[0] + 0.3; x + 2.5 <= lb[2]; x += 2.5) {
const corners: Vec[] = [[x, y], [x + 2.5, y], [x + 2.5, y + 5], [x, y + 5]].map((q) => rotate(q as Vec, ang));
if (!corners.every((q) => pointInRings(q, a.rings))) continue;
const cc = rotate([x + 1.25, y + 2.5], ang);
if (!inBounds(cc) || blockedByBuilding(cc, 2)) continue;
// разметка места рисуем всегда, машину — с вероятностью
props.push({ id: `ps${a.id}_${props.length}`, kind: 'parking_spot', pos: cc, rot: ang, w: 2.5, d: 5, shapes: [{ t: 'line', x1: -1.25, y1: -2.5, x2: -1.25, y2: 2.5, s: 'line' }, { t: 'line', x1: 1.25, y1: -2.5, x2: 1.25, y2: 2.5, s: 'line' }], blocksMove: false, blocksSight: false });
if (pr.chance(fill)) {
const flip = row % 2 === 1 ? Math.PI : 0;
props.push({ id: `c${a.id}_${props.length}`, kind: 'car', pos: cc, rot: ang + flip + pr.range(-0.04, 0.04), w: 1.8, d: 4.4, shapes: carShapes(), blocksMove: true, blocksSight: false, color: pr.pick(CAR_COLORS), tags: ['vehicle'] });
}
}
y += row % 2 === 0 ? 5 + 6 : 5 + 0.0;
row++;
}
}
// Машины вдоль жилых улиц (параллельная парковка)
for (const r of roads) {
if (!['residential', 'tertiary', 'living_street', 'unclassified'].includes(r.kind) || r.tunnel || r.bridge) continue;
const rr = new Rng(hashString(r.id) ^ seed);
if (!rr.chance(0.6)) continue;
for (let i = 1; i < r.pts.length; i++) {
const a = r.pts[i - 1], b = r.pts[i];
const L = dist(a, b);
if (L < 12) continue;
const dir: Vec = [(b[0] - a[0]) / L, (b[1] - a[1]) / L];
const nrm: Vec = [-dir[1], dir[0]];
for (const side of [1, -1]) {
for (let s = 6; s < L - 6; s += 6) {
if (!rr.chance(0.28)) continue;
const off = r.width / 2 - 1.1;
const p: Vec = [a[0] + dir[0] * s + nrm[0] * off * side, a[1] + dir[1] * s + nrm[1] * off * side];
if (!inBounds(p) || blockedByBuilding(p, 1.5)) continue;
props.push({ id: `cs${r.id}_${props.length}`, kind: 'car', pos: p, rot: Math.atan2(dir[1], dir[0]) + (side > 0 ? Math.PI / 2 : -Math.PI / 2) + rr.range(-0.03, 0.03), w: 1.8, d: 4.4, shapes: carShapes(), blocksMove: true, blocksSight: false, color: rr.pick(CAR_COLORS), tags: ['vehicle'] });
}
}
}
}
// ---- Задания на интерьеры: здание + ближайшие дороги
const jobs: InteriorJob[] = buildings.map((b) => {
const bb = bboxOf(b.footprint);
const segs = segGrid.query([bb[0] - 40, bb[1] - 40, bb[2] + 40, bb[3] + 40])
.filter((s) => !['rail', 'tram', 'river', 'stream', 'canal', 'ditch'].includes(s.road.kind))
.map((s): [Vec, Vec, number] => [s.a, s.b, s.road.kind === 'footway' || s.road.kind === 'path' || s.road.kind === 'cycleway' ? 0.6 : s.road.kind === 'service' ? 0.8 : 1]);
return { building: b, roadSegs: segs.slice(0, 200) };
});
const world: World = {
version: 1,
id: `w${Date.now().toString(36)}`,
name,
createdAt: new Date().toISOString(),
seed,
origin: { lat: lat0, lon: lon0 },
bbox,
bounds,
areas,
roads: roads.filter((r) => r.pts.some((p) => inBounds(p, 200))),
buildings,
props: props.filter((p) => inBounds(p.pos, 2)),
models: {},
attribution: '© OpenStreetMap contributors (ODbL)',
};
return { world, jobs };
}
function distToRing(p: Vec, r: Ring): number {
let d = Infinity;
for (let i = 0; i < r.length; i++) d = Math.min(d, distToSegment(p, r[i], r[(i + 1) % r.length]));
return d;
}
+126
View File
@@ -0,0 +1,126 @@
import type { BuildingKind } from '../types';
import { Rng } from '../util/rng';
export interface ClassifyInput {
tags: Record<string, string>;
/** Теги POI-точек внутри контура. */
pois: Record<string, string>[];
area: number;
landuse?: string;
/** Есть ли рядом здание побольше (для гаражей/сараев во дворах). */
nearBigger: boolean;
}
const BUILDING_TAG: Record<string, BuildingKind> = {
house: 'house', detached: 'house', semidetached_house: 'house', terrace: 'house', bungalow: 'house',
residential: 'apartments', apartments: 'apartments', dormitory: 'apartments',
hut: 'shed', cabin: 'house', static_caravan: 'shed',
commercial: 'office', office: 'office', retail: 'retail', supermarket: 'retail', kiosk: 'retail', shop: 'retail',
industrial: 'industrial', manufacture: 'industrial', factory: 'industrial', warehouse: 'warehouse', storage_tank: 'shed',
hangar: 'warehouse', service: 'shed', transformer_tower: 'shed', hotel: 'hotel',
school: 'school', kindergarten: 'school', college: 'school', university: 'school',
hospital: 'hospital', clinic: 'hospital',
church: 'church', chapel: 'church', cathedral: 'church', mosque: 'church', synagogue: 'church', temple: 'church', religious: 'church',
civic: 'civic', public: 'civic', government: 'civic', townhall: 'civic', fire_station: 'civic', train_station: 'civic',
sports_hall: 'warehouse', sports_centre: 'warehouse', stadium: 'civic',
garage: 'garage', garages: 'garage', carport: 'garage', parking: 'garage',
shed: 'shed', barn: 'farm', farm_auxiliary: 'farm', stable: 'farm', cowshed: 'farm', greenhouse: 'farm', farm: 'house',
restaurant: 'restaurant',
};
const AMENITY_KIND: Record<string, BuildingKind> = {
restaurant: 'restaurant', cafe: 'restaurant', fast_food: 'restaurant', bar: 'restaurant', pub: 'restaurant', biergarten: 'restaurant', ice_cream: 'restaurant',
bank: 'office', pharmacy: 'retail', post_office: 'office', townhall: 'civic', police: 'civic', fire_station: 'civic', library: 'civic',
courthouse: 'civic', community_centre: 'civic', theatre: 'civic', cinema: 'civic', arts_centre: 'civic',
school: 'school', kindergarten: 'school', college: 'school', university: 'school',
hospital: 'hospital', clinic: 'hospital', doctors: 'office', dentist: 'office', veterinary: 'office',
place_of_worship: 'church', fuel: 'retail', car_wash: 'garage', marketplace: 'retail',
};
const NON_HOME_AMENITY = new Set(Object.keys(AMENITY_KIND));
/** Подтип для ключевого POI внутри здания: 'supermarket', 'bakery', 'restaurant', 'bank'… */
export function useOf(t: Record<string, string>): string | undefined {
if (t.shop) return t.shop;
if (t.amenity && NON_HOME_AMENITY.has(t.amenity)) return t.amenity;
if (t.office) return 'office';
if (t.craft) return 'workshop';
if (t.tourism === 'hotel' || t.tourism === 'hostel' || t.tourism === 'guest_house') return 'hotel';
if (t.healthcare) return 'clinic';
return undefined;
}
export function classify(inp: ClassifyInput, rng: Rng): { kind: BuildingKind; use?: string } {
const t = inp.tags;
const b = t.building;
let kind: BuildingKind | undefined = BUILDING_TAG[b];
// Сами теги здания (магазин, амнити на контуре)
const own = useOf(t);
const poiUses = inp.pois.map(useOf).filter(Boolean) as string[];
const use = own ?? poiUses[0];
const commercialPoi = (u?: string): BuildingKind | undefined => {
if (!u) return undefined;
if (u === 'hotel') return 'hotel';
if (u === 'office') return 'office';
if (u === 'workshop') return 'industrial';
if (u === 'clinic') return 'office';
if (AMENITY_KIND[u]) return AMENITY_KIND[u];
return 'retail'; // shop=*
};
if (!kind || b === 'yes' || b === 'commercial' || b === 'retail') {
const ck = commercialPoi(use);
if (ck) kind = ck;
}
if (t.amenity && AMENITY_KIND[t.amenity]) kind = AMENITY_KIND[t.amenity];
if (t.tourism === 'hotel') kind = 'hotel';
if (!kind) {
const a = inp.area;
const lu = inp.landuse;
if (a < 20) kind = 'shed';
else if (a < 45 && inp.nearBigger) kind = rng.chance(0.6) ? 'garage' : 'shed';
else if (lu === 'industrial') kind = a > 1500 ? 'warehouse' : a < 60 ? 'shed' : 'industrial';
else if (lu === 'commercial') kind = a > 2500 ? 'retail' : 'office';
else if (lu === 'retail') kind = 'retail';
else if (lu === 'farmyard' || lu === 'farmland' || lu === 'meadow') kind = a > 150 ? 'farm' : a < 60 ? 'shed' : 'house';
else if (a < 280) kind = 'house';
else if (a < 2500) kind = 'apartments';
else kind = 'warehouse';
}
// магазин на первом этаже многоэтажного жилого дома
if ((kind === 'retail' || kind === 'restaurant') && (b === 'apartments' || b === 'residential')) kind = 'mixed';
return { kind, use };
}
export function parseLevels(t: Record<string, string>): { levels: number; source: 'osm' | 'height' } | null {
const lv = parseFloat(t['building:levels']);
if (isFinite(lv) && lv >= 1) return { levels: Math.min(60, Math.round(lv)), source: 'osm' };
const h = parseFloat(t.height ?? t['building:height']);
if (isFinite(h) && h > 2) return { levels: Math.max(1, Math.min(60, Math.round(h / 3.2))), source: 'height' };
return null;
}
/** Этажность по умолчанию по виду и площади. */
export function defaultLevels(kind: BuildingKind, area: number, rng: Rng): number {
switch (kind) {
case 'house': return area < 70 ? 1 : area < 110 ? rng.pick([1, 2, 2]) : 2;
case 'apartments': return area < 400 ? rng.pick([3, 3, 4]) : rng.pick([4, 5, 5, 6]);
case 'mixed': return rng.pick([3, 4, 4, 5]);
case 'office': return area < 300 ? 2 : rng.pick([3, 4, 5]);
case 'hotel': return rng.pick([3, 4, 5]);
case 'hospital': return rng.pick([3, 4]);
case 'school': return area < 600 ? 2 : rng.pick([2, 3]);
case 'civic': return 2;
case 'retail': return area > 1500 ? 1 : area < 200 ? 1 : rng.pick([1, 2]);
case 'restaurant': return 1;
default: return 1;
}
}
/** Разумные пределы этажности для вида (если берём по соседям). */
export function clampLevels(kind: BuildingKind, n: number): number {
const max: Partial<Record<BuildingKind, number>> = { house: 3, shed: 1, garage: 1, farm: 2, warehouse: 2, industrial: 3, church: 1, restaurant: 3 };
return Math.max(1, Math.min(max[kind] ?? 30, n));
}
+77
View File
@@ -0,0 +1,77 @@
// Финальная упаковка мира: общие модели предметов и округление координат до сантиметра.
import type { Building, Prop, Shape, World } from '../types';
const r2 = (v: number) => Math.round(v * 100) / 100;
const r3 = (v: number) => Math.round(v * 1000) / 1000;
export class ModelLibrary {
private index = new Map<string, string>();
/** Модели, добавленные с последнего takeNew() — чтобы воркер отдавал только новые. */
private fresh: Record<string, Shape[]> = {};
constructor(public models: Record<string, Shape[]>, private prefix = '') {
for (const [id, shapes] of Object.entries(models)) this.index.set(JSON.stringify(shapes), id);
}
intern(p: Prop) {
if (!p.shapes) return;
const shapes = p.shapes.map(roundShape);
const key = JSON.stringify(shapes);
let id = this.index.get(key);
if (!id) {
id = `${p.kind}#${this.prefix}${this.index.size}`;
this.index.set(key, id);
this.models[id] = shapes;
this.fresh[id] = shapes;
}
p.model = id;
delete p.shapes;
}
takeNew(): Record<string, Shape[]> {
const f = this.fresh;
this.fresh = {};
return f;
}
}
function roundShape(s: Shape): Shape {
if (s.t === 'line') return { ...s, x1: r2(s.x1), y1: r2(s.y1), x2: r2(s.x2), y2: r2(s.y2) };
if (s.t === 'circle') return { ...s, x: r2(s.x), y: r2(s.y), r: r2(s.r) };
return { ...s, x: r2(s.x), y: r2(s.y), w: r2(s.w), h: r2(s.h), ...(s.r !== undefined ? { r: r2(s.r) } : {}) };
}
function roundProp(p: Prop) {
p.pos = [r2(p.pos[0]), r2(p.pos[1])];
p.rot = r3(p.rot);
p.w = r2(p.w);
p.d = r2(p.d);
if (!p.tags?.length) delete p.tags;
if (!p.roomId) delete p.roomId;
if (!p.color) delete p.color;
}
const rv = (v: [number, number]): [number, number] => [r2(v[0]), r2(v[1])];
export function finalizeBuilding(b: Building, lib: ModelLibrary) {
b.footprint = b.footprint.map(rv);
b.holes = b.holes.map((h) => h.map(rv));
b.area = Math.round(b.area);
for (const f of b.floors) {
for (const r of f.rooms) {
r.rings = r.rings.map((rr) => rr.map(rv));
r.labelPos = rv(r.labelPos);
}
for (const w of f.walls) { w.a = rv(w.a); w.b = rv(w.b); }
for (const d of f.doors) { d.a = rv(d.a); d.b = rv(d.b); d.swingTo = rv(d.swingTo); }
for (const w of f.windows) { w.a = rv(w.a); w.b = rv(w.b); }
for (const s of f.stairs) { s.rings = s.rings.map((rr) => rr.map(rv)); s.from = rv(s.from); s.to = rv(s.to); }
for (const p of f.props) { roundProp(p); lib.intern(p); }
}
}
export function finalizeWorld(w: World, buildings = true) {
const lib = new ModelLibrary(w.models);
for (const a of w.areas) a.rings = a.rings.map((r) => r.map(rv));
for (const r of w.roads) r.pts = r.pts.map(rv);
for (const p of w.props) { roundProp(p); lib.intern(p); }
if (buildings) for (const b of w.buildings) finalizeBuilding(b, lib);
}
+476
View File
@@ -0,0 +1,476 @@
// Расстановка мебели по правилам для каждого типа комнаты.
// Работает в сетке здания: предмет занимает клетки, «спинкой» встаёт к стене,
// не перекрывает двери (зона открывания) и высокими предметами не закрывает окна.
import type { Building, Prop, Vec } from '../types';
import type { DoorL, FloorG, Grid, WindowL } from './interior';
import { C } from './interior';
import * as K from './catalog';
import type { Def } from './catalog';
import { Rng } from '../util/rng';
import { distToSegment } from '../util/geom';
import { CAR_COLORS } from './props';
export interface PropL {
prop: Prop;
pos: Vec;
rot: number;
room: number;
}
interface Ctx {
g: Grid;
F: FloorG;
doors: DoorL[];
windows: WindowL[];
building: Building;
rng: Rng;
}
type Dir = 'N' | 'E' | 'S' | 'W';
const ROT: Record<Dir, number> = { N: 0, S: Math.PI, W: -Math.PI / 2, E: Math.PI / 2 };
export function furnishFloor(ctx: Ctx): PropL[] {
const { g, F, rng } = ctx;
const n = g.gw * g.gh;
const occ = new Uint8Array(n); // 0 свободно, 1 занято, 2 держать свободным
const win = new Uint8Array(n);
const out: PropL[] = [];
const cellsOf = new Map<number, number[]>();
for (let i = 0; i < n; i++) {
const r = F.labels[i];
if (r < 0 || !g.mask[i]) continue;
if (!cellsOf.has(r)) cellsOf.set(r, []);
cellsOf.get(r)!.push(i);
}
const cc = (i: number): Vec => [((i % g.gw) + 0.5) * C, (((i / g.gw) | 0) + 0.5) * C];
// зоны дверей и окон
const around = (a: Vec, b: Vec, r: number, fn: (i: number, p: Vec) => void) => {
const x0 = Math.max(0, Math.floor((Math.min(a[0], b[0]) - r) / C)), x1 = Math.min(g.gw - 1, Math.floor((Math.max(a[0], b[0]) + r) / C));
const y0 = Math.max(0, Math.floor((Math.min(a[1], b[1]) - r) / C)), y1 = Math.min(g.gh - 1, Math.floor((Math.max(a[1], b[1]) + r) / C));
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) { const i = y * g.gw + x; if (F.labels[i] >= 0) fn(i, cc(i)); }
};
for (const d of ctx.doors) {
const m: Vec = [(d.a[0] + d.b[0]) / 2, (d.a[1] + d.b[1]) / 2];
const half = Math.hypot(d.b[0] - d.a[0], d.b[1] - d.a[1]) / 2;
around(d.a, d.b, half + 0.7, (i, p) => {
if (distToSegment(p, d.a, d.b) < 0.95 || Math.hypot(p[0] - m[0], p[1] - m[1]) < half + 0.6) occ[i] = 2;
});
}
for (const w of ctx.windows) around(w.a, w.b, 0.6, (i, p) => { if (distToSegment(p, w.a, w.b) < 0.6) win[i] = 1; });
// лестницы — вне мебели
for (const s of F.stairs) for (let y = s.block.y0; y < s.block.y1; y++) for (let x = s.block.x0; x < s.block.x1; x++) occ[y * g.gw + x] = 1;
const lab = (x: number, y: number) => (x < 0 || y < 0 || x >= g.gw || y >= g.gh ? -1 : F.labels[y * g.gw + x]);
const free = (x: number, y: number, room: number, tall?: boolean) => {
if (x < 0 || y < 0 || x >= g.gw || y >= g.gh) return false;
const i = y * g.gw + x;
return F.labels[i] === room && g.mask[i] === 1 && occ[i] === 0 && !(tall && win[i]);
};
const emit = (def: Def, pos: Vec, rot: number, room: number, cells: number[], frontCells: number[], color?: string) => {
for (const i of cells) occ[i] = 1;
for (const i of frontCells) if (occ[i] === 0) occ[i] = 2;
out.push({
pos, rot, room,
prop: {
id: '', kind: def.kind, pos: [0, 0], rot: 0, w: def.w, d: def.d, shapes: def.shapes,
blocksMove: def.blocksMove ?? true, blocksSight: !!def.tall, tags: def.tags, color,
},
});
return true;
};
/** Поставить предмет спинкой к стене. */
const atWall = (room: number, def: Def, opt: { corner?: boolean; window?: boolean; tries?: number } = {}): boolean => {
const cells = cellsOf.get(room);
if (!cells) return false;
const A = Math.max(1, Math.ceil(def.w / C - 1e-6)), D = Math.max(1, Math.ceil(def.d / C - 1e-6));
const FD = Math.ceil((def.front ?? 0) / C - 1e-6);
let best: { s: number; dir: Dir; x0: number; y0: number; x1: number; y1: number } | null = null;
for (const i of cells) {
const cx = i % g.gw, cy = (i / g.gw) | 0;
for (const dir of ['N', 'E', 'S', 'W'] as Dir[]) {
let x0: number, y0: number, x1: number, y1: number;
if (dir === 'N') { x0 = cx; y0 = cy; x1 = cx + A; y1 = cy + D; }
else if (dir === 'S') { x0 = cx; y0 = cy - D + 1; x1 = cx + A; y1 = cy + 1; }
else if (dir === 'W') { x0 = cx; y0 = cy; x1 = cx + D; y1 = cy + A; }
else { x0 = cx - D + 1; y0 = cy; x1 = cx + 1; y1 = cy + A; }
let ok = true;
for (let y = y0; y < y1 && ok; y++) for (let x = x0; x < x1 && ok; x++) if (!free(x, y, room, def.tall)) ok = false;
if (!ok) continue;
// сзади должна быть стена по всей длине
let wallOk = true;
if (dir === 'N') for (let x = x0; x < x1; x++) wallOk &&= lab(x, y0 - 1) !== room;
if (dir === 'S') for (let x = x0; x < x1; x++) wallOk &&= lab(x, y1) !== room;
if (dir === 'W') for (let y = y0; y < y1; y++) wallOk &&= lab(x0 - 1, y) !== room;
if (dir === 'E') for (let y = y0; y < y1; y++) wallOk &&= lab(x1, y) !== room;
if (!wallOk) continue;
// перед предметом — проход
let frontOk = true;
for (let k = 1; k <= FD && frontOk; k++) {
if (dir === 'N') for (let x = x0; x < x1; x++) frontOk &&= lab(x, y1 - 1 + k) === room && occ[(y1 - 1 + k) * g.gw + x] !== 1;
if (dir === 'S') for (let x = x0; x < x1; x++) frontOk &&= lab(x, y0 - k) === room && occ[(y0 - k) * g.gw + x] !== 1;
if (dir === 'W') for (let y = y0; y < y1; y++) frontOk &&= lab(x1 - 1 + k, y) === room && occ[y * g.gw + x1 - 1 + k] !== 1;
if (dir === 'E') for (let y = y0; y < y1; y++) frontOk &&= lab(x0 - k, y) === room && occ[y * g.gw + x0 - k] !== 1;
}
if (!frontOk) continue;
let s = rng.next();
if (opt.corner) {
const sideA = dir === 'N' || dir === 'S' ? lab(x0 - 1, dir === 'N' ? y0 : y1 - 1) !== room : lab(dir === 'W' ? x0 : x1 - 1, y0 - 1) !== room;
const sideB = dir === 'N' || dir === 'S' ? lab(x1, dir === 'N' ? y0 : y1 - 1) !== room : lab(dir === 'W' ? x0 : x1 - 1, y1) !== room;
s += (sideA ? 2 : 0) + (sideB ? 2 : 0);
}
if (opt.window) {
let wn = 0;
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) wn += win[y * g.gw + x];
s += wn ? 3 : 0;
}
if (!best || s > best.s) best = { s, dir, x0, y0, x1, y1 };
}
}
if (!best) return false;
const { dir, x0, y0, x1, y1 } = best;
const inset = 0.07 + def.d / 2;
let pos: Vec;
if (dir === 'N') pos = [((x0 + x1) / 2) * C, y0 * C + inset];
else if (dir === 'S') pos = [((x0 + x1) / 2) * C, y1 * C - inset];
else if (dir === 'W') pos = [x0 * C + inset, ((y0 + y1) / 2) * C];
else pos = [x1 * C - inset, ((y0 + y1) / 2) * C];
const used: number[] = [];
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) used.push(y * g.gw + x);
const front: number[] = [];
for (let k = 1; k <= FD; k++) {
if (dir === 'N') for (let x = x0; x < x1; x++) front.push((y1 - 1 + k) * g.gw + x);
if (dir === 'S') for (let x = x0; x < x1; x++) front.push((y0 - k) * g.gw + x);
if (dir === 'W') for (let y = y0; y < y1; y++) front.push(y * g.gw + x1 - 1 + k);
if (dir === 'E') for (let y = y0; y < y1; y++) front.push(y * g.gw + x0 - k);
}
return emit(def, pos, ROT[dir], room, used, front.filter((i) => i >= 0 && i < n));
};
/** Поставить предмет отдельно (стол, кластер), с отступом margin клеток, ближе к центру комнаты. */
const inMiddle = (room: number, def: Def, margin = 1, center?: Vec, color?: string): boolean => {
const cells = cellsOf.get(room);
if (!cells) return false;
const c = center ?? roomPole(room);
let best: { s: number; x0: number; y0: number; x1: number; y1: number; rot: boolean } | null = null;
for (const rotd of [false, true]) {
const A = Math.max(1, Math.ceil((rotd ? def.d : def.w) / C - 1e-6)), D = Math.max(1, Math.ceil((rotd ? def.w : def.d) / C - 1e-6));
for (const i of cells) {
const x0 = i % g.gw, y0 = (i / g.gw) | 0;
let ok = true;
for (let y = y0 - margin; y < y0 + D + margin && ok; y++)
for (let x = x0 - margin; x < x0 + A + margin && ok; x++) {
const inner = x >= x0 && x < x0 + A && y >= y0 && y < y0 + D;
if (inner) ok = free(x, y, room);
else ok = lab(x, y) === room && occ[y * g.gw + x] !== 1;
}
if (!ok) continue;
const s = -Math.hypot((x0 + A / 2) * C - c[0], (y0 + D / 2) * C - c[1]) + rng.next() * 0.3;
if (!best || s > best.s) best = { s, x0, y0, x1: x0 + A, y1: y0 + D, rot: rotd };
}
}
if (!best) return false;
const used: number[] = [];
for (let y = best.y0; y < best.y1; y++) for (let x = best.x0; x < best.x1; x++) used.push(y * g.gw + x);
return emit(def, [((best.x0 + best.x1) / 2) * C, ((best.y0 + best.y1) / 2) * C], best.rot ? Math.PI / 2 : rng.chance(0.5) ? 0 : Math.PI, room, used, [], color);
};
/** Сетка одинаковых предметов (столики, парты, станки). */
const lattice = (room: number, make: () => Def, gapX: number, gapY: number, margin: number, alongLong = true, max = 400, faceRot?: number): number => {
const cells = cellsOf.get(room);
if (!cells) return 0;
const bb = roomBox(room);
const longX = bb[2] - bb[0] >= bb[3] - bb[1];
const def0 = make();
const rotd = alongLong ? !longX : longX;
const A = Math.ceil((rotd ? def0.d : def0.w) / C - 1e-6), D = Math.ceil((rotd ? def0.w : def0.d) / C - 1e-6);
const sx = A + Math.round(gapX / C), sy = D + Math.round(gapY / C);
let placed = 0;
for (let y = bb[1] + margin; y + D <= bb[3] - margin && placed < max; y += sy)
for (let x = bb[0] + margin; x + A <= bb[2] - margin && placed < max; x += sx) {
let ok = true;
for (let yy = y - 1; yy < y + D + 1 && ok; yy++)
for (let xx = x - 1; xx < x + A + 1 && ok; xx++) {
const inner = xx >= x && xx < x + A && yy >= y && yy < y + D;
ok = inner ? free(xx, yy, room) : lab(xx, yy) === room && occ[yy * g.gw + xx] !== 1;
}
if (!ok) continue;
const used: number[] = [];
for (let yy = y; yy < y + D; yy++) for (let xx = x; xx < x + A; xx++) used.push(yy * g.gw + xx);
emit(make(), [(x + A / 2) * C, (y + D / 2) * C], faceRot ?? (rotd ? Math.PI / 2 : 0), room, used, []);
placed++;
}
return placed;
};
/** Ряды стеллажей вдоль длинной стороны комнаты с проходами. */
const rows = (room: number, make: (L: number) => Def, depth: number, aisle: number, margin: number, maxLen = 12) => {
const bb = roomBox(room);
const longX = bb[2] - bb[0] >= bb[3] - bb[1];
const D = Math.ceil(depth / C), step = D + Math.round(aisle / C);
const [a0, a1, b0, b1] = longX ? [bb[1], bb[3], bb[0], bb[2]] : [bb[0], bb[2], bb[1], bb[3]];
for (let a = a0 + margin; a + D <= a1 - margin; a += step) {
let runStart = -1;
const flush = (end: number) => {
let s = runStart;
while (end - s >= 4) {
const len = Math.min(end - s, Math.round(maxLen / C));
const L = len * C - 0.1;
const used: number[] = [];
for (let k = s; k < s + len; k++) for (let dd = a; dd < a + D; dd++) used.push(longX ? dd * g.gw + k : k * g.gw + dd);
const pos: Vec = longX ? [((s + s + len) / 2) * C, (a + D / 2) * C] : [(a + D / 2) * C, ((s + s + len) / 2) * C];
emit(make(L), pos, longX ? 0 : Math.PI / 2, room, used, []);
s += len + 3;
}
runStart = -1;
};
for (let k = b0 + margin; k <= b1 - margin; k++) {
let ok = k < b1 - margin;
for (let dd = a - 1; dd < a + D + 1 && ok; dd++) {
const x = longX ? k : dd, y = longX ? dd : k;
const inner = dd >= a && dd < a + D;
ok = inner ? free(x, y, room, true) : lab(x, y) === room && occ[y * g.gw + x] !== 1;
}
if (ok && runStart < 0) runStart = k;
if (!ok && runStart >= 0) flush(k);
}
}
};
const roomBox = (room: number): [number, number, number, number] => {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const i of cellsOf.get(room) ?? []) {
const x = i % g.gw, y = (i / g.gw) | 0;
x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x + 1); y1 = Math.max(y1, y + 1);
}
return [x0, y0, x1, y1];
};
const roomPole = (room: number): Vec => {
const bb = roomBox(room);
return [((bb[0] + bb[2]) / 2) * C, ((bb[1] + bb[3]) / 2) * C];
};
const areaOf = (room: number) => (cellsOf.get(room)?.length ?? 0) * C * C;
const shortSide = (room: number) => { const bb = roomBox(room); return Math.min(bb[2] - bb[0], bb[3] - bb[1]) * C; };
const use = ctx.building.use ?? '';
for (const [room, cells] of cellsOf) {
const R = F.rooms[room];
if (R.dead || !cells.length) continue;
const a = areaOf(room);
switch (R.kind) {
case 'bedroom': {
if (a >= 10 && shortSide(room) >= 2.8) {
if (!atWall(room, K.bedWithNightstands())) atWall(room, K.bed(true));
} else if (!atWall(room, K.bed(a >= 9), { corner: true })) atWall(room, K.bed(false), { corner: true });
atWall(room, K.wardrobe(a > 12 ? 1.8 : 1.2), { corner: true });
if (a > 11) atWall(room, K.desk(1.2), { window: true });
if (a > 14 && rng.chance(0.5)) atWall(room, K.armchair(), { corner: true });
if (rng.chance(0.3)) atWall(room, K.plant(), { corner: true });
break;
}
case 'living': {
const studio = a < 30 && !F.rooms.some((r) => r.unit === R.unit && r.kind === 'bedroom' && !r.dead);
if (studio) {
atWall(room, K.bed(true), { corner: true });
if (!F.rooms.some((r) => r.unit === R.unit && r.kind === 'kitchen' && !r.dead)) atWall(room, K.kitchenLine(1.8));
}
if (!atWall(room, K.sofaSet())) atWall(room, K.sofa(1.8));
atWall(room, K.tvStand());
if (a > 16) atWall(room, K.armchair(), { corner: true });
atWall(room, K.bookshelf(rng.pick([0.8, 1.0, 1.6])));
if (a > 20 && !studio) inMiddle(room, K.diningTable(a > 26 ? 6 : 4), 1);
atWall(room, K.plant(), { corner: true });
if (rng.chance(0.5)) atWall(room, K.plant(), { corner: true });
break;
}
case 'kitchen': {
let placed = false;
for (const L of [3.6, 3.0, 2.4, 1.8, 1.2]) if (atWall(room, K.kitchenLine(L))) { placed = true; break; }
if (!placed) inMiddle(room, K.kitchenLine(1.2), 0);
atWall(room, K.fridge(), { corner: true });
if (a >= 8) inMiddle(room, K.diningTable(4), 1);
break;
}
case 'bath': {
if (a >= 4.2 && shortSide(room) >= 1.7) { if (!atWall(room, K.bathtub(), { corner: true })) atWall(room, K.shower(), { corner: true }); }
else atWall(room, K.shower(), { corner: true });
atWall(room, K.toilet());
atWall(room, K.basin());
if (a > 6) atWall(room, K.washer());
break;
}
case 'wc': {
const n = ctx.building.kind === 'house' || a < 5 ? 1 : Math.min(4, Math.floor(a / 2.5));
for (let k = 0; k < n; k++) atWall(room, K.toilet(), { corner: k === 0 });
atWall(room, K.basin());
if (n > 1) atWall(room, K.basin());
break;
}
case 'hall': {
atWall(room, K.coatRack());
if (a > 5) atWall(room, K.shoeCabinet());
if (a > 9) atWall(room, K.plant(), { corner: true });
if (ctx.building.kind === 'civic' && a > 20) { atWall(room, K.counter(2.4)); atWall(room, K.sofa(2.0)); }
break;
}
case 'landing':
if (F.level === 0) atWall(room, K.mailboxes());
break;
case 'lobby': {
atWall(room, K.mailboxes());
atWall(room, K.plant(), { corner: true });
break;
}
case 'corridor': {
if (rng.chance(0.4)) atWall(room, K.plant(), { corner: true });
break;
}
case 'utility': {
atWall(room, K.washer());
atWall(room, K.boiler(), { corner: true });
atWall(room, K.shelf(1.0));
break;
}
case 'storage':
case 'backroom': {
for (let k = 0; k < 8; k++) if (!atWall(room, K.shelf(rng.pick([1.0, 1.2, 1.8])))) break;
for (let k = 0; k < (a > 15 ? 3 : 1); k++) inMiddle(room, K.crate(), 1);
break;
}
case 'sales': {
if (['clothes', 'shoes', 'boutique', 'fashion'].includes(use)) {
lattice(room, K.clothesRack, 1.0, 1.2, 2);
atWall(room, K.counter(1.6));
for (let k = 0; k < 6; k++) if (!atWall(room, K.wallShelf(2.0))) break;
} else if (['bakery', 'cafe', 'butcher', 'pastry', 'deli', 'confectionery'].includes(use) || a < 25) {
if (!atWall(room, K.counter(Math.min(3.0, Math.max(1.2, shortSide(room) - 1))))) inMiddle(room, K.counter(1.2), 1);
for (let k = 0; k < 3; k++) if (!atWall(room, K.wallShelf(1.6))) break;
} else {
// супермаркет / обычный магазин: кассы у входа, стеллажи рядами, холодильники у стен
const ent = ctx.doors.find((d) => d.r2 === -1 && d.r1 === room);
const nCheck = a > 400 ? 4 : a > 120 ? 2 : 1;
for (let k = 0; k < nCheck; k++) inMiddle(room, K.checkout(), 1, ent ? [(ent.a[0] + ent.b[0]) / 2, (ent.a[1] + ent.b[1]) / 2] : undefined);
if (use === 'supermarket' || a > 150) for (let k = 0; k < 4; k++) if (!atWall(room, K.fridgeDisplay(2.4))) break;
for (let k = 0; k < 10; k++) if (!atWall(room, K.wallShelf(rng.pick([1.8, 2.4])))) break;
rows(room, K.gondola, 1.0, 1.6, 2, 10);
}
if (a > 20) atWall(room, K.plant(), { corner: true });
break;
}
case 'dining': {
atWall(room, K.counter(Math.min(3.0, Math.max(1.4, shortSide(room) * 0.6))));
if (lattice(room, a > 30 && rng.chance(0.5) ? K.cafeTable : () => K.diningTable(4), 0.8, 0.9, 1) === 0) inMiddle(room, K.cafeTable(), 1);
atWall(room, K.plant(), { corner: true });
break;
}
case 'prokitchen': {
for (const L of [2.4, 1.8, 1.2]) if (atWall(room, K.prepTable(L))) break;
atWall(room, K.proStove());
atWall(room, K.fridge(), { corner: true });
atWall(room, K.fridge(), { corner: true });
if (a > 14) inMiddle(room, K.prepTable(1.6), 1);
atWall(room, K.shelf(1.2));
break;
}
case 'office': {
atWall(room, K.desk(1.4), { window: true });
if (a > 12) atWall(room, K.desk(1.4), { window: true });
atWall(room, K.filing(), { corner: true });
atWall(room, K.bookshelf(1.0));
if (rng.chance(0.6)) atWall(room, K.plant(), { corner: true });
break;
}
case 'openoffice': {
lattice(room, K.officeCluster, 1.4, 1.2, 2);
for (let k = 0; k < 3; k++) atWall(room, K.filing(), { corner: true });
atWall(room, K.plant(), { corner: true });
atWall(room, K.plant(), { corner: true });
break;
}
case 'meeting': {
const bb = roomBox(room);
const L = Math.max(1.6, Math.min(6, (Math.max(bb[2] - bb[0], bb[3] - bb[1]) * C) - 2.2));
if (!inMiddle(room, K.meetingTable(L), 1)) inMiddle(room, K.meetingTable(1.6), 1);
atWall(room, K.board(2.0));
break;
}
case 'staff': {
inMiddle(room, K.diningTable(4), 1);
atWall(room, K.kitchenLine(1.8));
atWall(room, K.sofa(1.8));
break;
}
case 'classroom': {
atWall(room, K.board(3.0));
inMiddle(room, K.teacherDesk(), 1);
lattice(room, K.studentDesk, 0.7, 0.5, 2);
atWall(room, K.bookshelf(1.6));
break;
}
case 'ward': {
for (let k = 0; k < Math.max(1, Math.min(4, Math.floor(a / 8))); k++) if (!atWall(room, K.hospitalBed())) break;
atWall(room, K.nightstand());
atWall(room, K.armchair(), { corner: true });
break;
}
case 'guestroom': {
if (!atWall(room, K.bedWithNightstands())) if (!atWall(room, K.bed(true))) atWall(room, K.bed(false));
atWall(room, K.desk(1.2), { window: true });
atWall(room, K.wardrobe(1.0), { corner: true });
atWall(room, K.armchair(), { corner: true });
break;
}
case 'workshop': {
for (let k = 0; k < 3; k++) atWall(room, K.workbench(rng.pick([1.6, 2.0])));
for (let k = 0; k < 4; k++) atWall(room, K.shelf(1.2));
inMiddle(room, K.machine(), 1);
break;
}
case 'hall_ind': {
const kindB = ctx.building.kind;
if (kindB === 'warehouse' && !['sports_hall', 'sports_centre'].includes(use)) {
rows(room, K.palletRack, 1.1, 3.2, 3, 16);
lattice(room, K.pallet, 2.0, 2.0, 2, true, 6);
} else if (kindB === 'farm') {
lattice(room, K.pallet, 2.5, 2.5, 2, true, 8);
for (let k = 0; k < 4; k++) atWall(room, K.shelf(1.8));
} else if (['sports_hall', 'sports_centre'].includes(use)) {
atWall(room, K.bookshelf(2.0));
} else {
lattice(room, K.machine, 2.4, 2.6, 3, true, 40);
for (let k = 0; k < 4; k++) atWall(room, K.workbench(2.0));
for (let k = 0; k < 6; k++) atWall(room, K.shelf(1.8));
}
break;
}
case 'nave': {
// алтарь у дальней от входа стены, скамьи рядами поперёк
const ent = ctx.doors.find((d) => d.r2 === -1);
const bb = roomBox(room);
const longX = bb[2] - bb[0] >= bb[3] - bb[1];
const cMid: Vec = [((bb[0] + bb[2]) / 2) * C, ((bb[1] + bb[3]) / 2) * C];
let far: Vec = cMid;
if (ent) {
const em: Vec = [(ent.a[0] + ent.b[0]) / 2, (ent.a[1] + ent.b[1]) / 2];
far = longX ? [em[0] < cMid[0] ? (bb[2] - 3) * C : (bb[0] + 3) * C, cMid[1]] : [cMid[0], em[1] < cMid[1] ? (bb[3] - 3) * C : (bb[1] + 3) * C];
}
inMiddle(room, K.altar(), 1, far);
const width = (longX ? bb[3] - bb[1] : bb[2] - bb[0]) * C;
const L = Math.max(1.5, Math.min(5, (width - 2.4) / 2));
lattice(room, () => K.pew(L), 1.6, 0.5, 2, false, 60, longX ? Math.PI / 2 : 0);
break;
}
case 'garage': {
if (a > 14 && shortSide(room) >= 2.6) inMiddle(room, K.carDef(), 0, undefined, rng.pick(CAR_COLORS));
atWall(room, K.workbench(1.6));
atWall(room, K.shelf(1.2));
break;
}
default:
break;
}
}
return out;
}
+36
View File
@@ -0,0 +1,36 @@
import type { OsmElement, BBox } from '../osm/overpass';
import type { World } from '../types';
import { buildCity, type InteriorJob } from './city';
import { generateInterior, type InteriorResult } from './interior';
import { finalizeWorld } from './finalize';
export type Progress = (stage: string, done: number, total: number) => void;
/** Применить результат генерации интерьера к зданию. */
export function applyInterior(world: World, buildingId: string, r: InteriorResult) {
const b = world.buildings.find((x) => x.id === buildingId);
if (!b) return;
b.floors = r.floors;
b.units = r.units;
b.beds = r.beds;
}
/** Синхронная генерация целиком (для CLI и тестов; в браузере интерьеры считаются в воркерах). */
export function generateWorldSync(elements: OsmElement[], bbox: BBox, name: string, seed: number, progress?: Progress): World {
const { world, jobs } = buildCity(elements, bbox, name, seed);
jobs.forEach((job, i) => {
progress?.('interiors', i, jobs.length);
try {
const r = generateInterior(job);
job.building.floors = r.floors;
job.building.units = r.units;
job.building.beds = r.beds;
} catch (e) {
console.warn('interior failed', job.building.id, e);
}
});
finalizeWorld(world);
return world;
}
export type { InteriorJob };
+1446
View File
File diff suppressed because it is too large. Load diff
+97
View File
@@ -0,0 +1,97 @@
// Полный конвейер в браузере: OSM → город → интерьеры в пуле воркеров → упаковка.
import type { OsmElement, BBox } from '../osm/overpass';
import type { World } from '../types';
import { buildCity, type InteriorJob } from './city';
import { finalizeWorld } from './finalize';
export type OnProgress = (text: string, frac: number) => void;
export async function generateWorld(elements: OsmElement[], bbox: BBox, name: string, seed: number, onProgress: OnProgress, signal?: AbortSignal): Promise<World> {
onProgress('Строю улицы, зоны и контуры зданий…', 0.05);
await tick();
const { world, jobs } = buildCity(elements, bbox, name, seed);
finalizeWorld(world, false);
onProgress(`Планировки: 0 / ${jobs.length}`, 0.1);
await runInteriors(world, jobs, (done) => onProgress(`Планировки: ${done} / ${jobs.length} зданий`, 0.1 + 0.88 * (done / Math.max(1, jobs.length))), signal);
onProgress('Готово', 1);
return world;
}
const tick = () => new Promise((r) => setTimeout(r, 0));
async function runInteriors(world: World, jobs: InteriorJob[], onDone: (n: number) => void, signal?: AbortSignal) {
const byId = new Map(world.buildings.map((b) => [b.id, b]));
// крупные здания раньше — меньше «хвост» в конце
const queue = jobs.slice().sort((a, b) => b.building.area * b.building.levels - a.building.area * a.building.levels);
const nWorkers = Math.max(1, Math.min(8, (navigator.hardwareConcurrency || 4) - 1));
const workers: Worker[] = [];
let done = 0;
try {
for (let i = 0; i < nWorkers; i++) {
const w = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
w.postMessage({ type: 'init', index: i });
workers.push(w);
}
} catch (e) {
console.warn('Workers unavailable, generating on main thread', e);
}
if (!workers.length) {
const { generateInterior } = await import('./interior');
const { finalizeBuilding, ModelLibrary } = await import('./finalize');
const lib = new ModelLibrary(world.models, 'm_');
for (const job of queue) {
const r = generateInterior(job);
Object.assign(job.building, r);
finalizeBuilding(job.building, lib);
onDone(++done);
if (done % 20 === 0) await tick();
}
return;
}
let batchNo = 0;
await new Promise<void>((resolve, reject) => {
let active = 0;
const feed = (w: Worker) => {
if (signal?.aborted) return reject(new DOMException('Отменено', 'AbortError'));
if (!queue.length) {
if (active === 0) resolve();
return;
}
// небольшие пачки: крупные здания по одному
const batch: InteriorJob[] = [];
let cost = 0;
while (queue.length && cost < 3000) {
const j = queue.shift()!;
batch.push(j);
cost += j.building.area * j.building.levels;
if (batch.length >= 30) break;
}
active++;
w.postMessage({ type: 'jobs', batch: batchNo++, jobs: batch });
};
for (const w of workers) {
w.onmessage = (ev) => {
const m = ev.data;
if (m.type !== 'done') return;
Object.assign(world.models, m.models);
for (const r of m.results) {
const b = byId.get(r.id);
if (!b) continue;
b.floors = r.floors;
b.units = r.units;
b.beds = r.beds;
b.footprint = r.footprint;
b.holes = r.holes;
b.area = r.area;
}
done += m.results.length;
onDone(done);
active--;
feed(w);
};
w.onerror = (e) => reject(new Error('Ошибка в воркере: ' + e.message));
feed(w);
}
}).finally(() => workers.forEach((w) => w.terminate()));
}
+18
View File
@@ -0,0 +1,18 @@
import type { Vec } from '../types';
const R = 6378137;
const D2R = Math.PI / 180;
/** Локальная равнопромежуточная проекция вокруг центра области: для нескольких км погрешность < 0.1%. */
export class Projector {
readonly k: number;
constructor(readonly lat0: number, readonly lon0: number) {
this.k = Math.cos(lat0 * D2R);
}
toXY(lat: number, lon: number): Vec {
return [R * (lon - this.lon0) * D2R * this.k, -R * (lat - this.lat0) * D2R];
}
toLatLon(p: Vec): { lat: number; lon: number } {
return { lat: this.lat0 - p[1] / R / D2R, lon: this.lon0 + p[0] / (R * D2R * this.k) };
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { Prop, Shape, Vec } from '../types';
import { Rng } from '../util/rng';
export const CAR_COLORS = ['#c94c4c', '#3d6fb6', '#e8e8e8', '#2b2b2b', '#8c9298', '#d9c27a', '#4f8a5b', '#b5b9bd', '#6b4a8c', '#e07b39'];
export function carShapes(): Shape[] {
return [
{ t: 'rect', x: 0, y: 0, w: 1.8, h: 4.4, r: 0.45, s: 'car' },
{ t: 'rect', x: 0, y: -0.75, w: 1.5, h: 0.55, r: 0.15, s: 'glass' }, // лобовое
{ t: 'rect', x: 0, y: 1.25, w: 1.45, h: 0.4, r: 0.12, s: 'glass' }, // заднее
{ t: 'rect', x: 0, y: 0.25, w: 1.5, h: 1.4, r: 0.2, s: 'dark' }, // крыша
];
}
export function treeProp(id: string, p: Vec, rng: Rng, t: Record<string, string>): Prop {
const scrub = !!t.scrub;
const r0 = scrub ? rng.range(0.8, 1.6) : t.orchard ? rng.range(1.6, 2.4) : rng.range(2.2, 4.2);
const r = Math.round(r0 * 5) / 5; // немного вариантов — общие модели
const shapes: Shape[] = [{ t: 'circle', x: 0, y: 0, r, s: 'green' }];
if (!scrub) shapes.push({ t: 'circle', x: 0, y: 0, r: 0.18, s: 'wood' });
return {
id, kind: scrub ? 'bush' : 'tree', pos: p, rot: 0, w: r * 2, d: r * 2, shapes,
// крона не мешает проходу, мешает только ствол; обзор частично закрывает
blocksMove: !scrub, blocksSight: false, tags: scrub ? ['cover'] : ['trunk:0.2'],
};
}
+31
View File
@@ -0,0 +1,31 @@
// Web Worker: генерирует интерьеры пачками зданий.
import { generateInterior } from './interior';
import { finalizeBuilding, ModelLibrary } from './finalize';
import type { InteriorJob } from './city';
let lib = new ModelLibrary({}, 'w0_');
self.onmessage = (ev: MessageEvent) => {
const msg = ev.data;
if (msg.type === 'init') {
lib = new ModelLibrary({}, `w${msg.index}_`);
return;
}
if (msg.type === 'jobs') {
const results = (msg.jobs as InteriorJob[]).map((job) => {
const b = job.building;
try {
const r = generateInterior(job);
b.floors = r.floors;
b.units = r.units;
b.beds = r.beds;
} catch (e) {
console.warn('interior failed', b.id, e);
b.floors = [];
}
finalizeBuilding(b, lib);
return { id: b.id, floors: b.floors, units: b.units, beds: b.beds, footprint: b.footprint, holes: b.holes, area: b.area };
});
(self as unknown as Worker).postMessage({ type: 'done', batch: msg.batch, results, models: lib.takeNew() });
}
};