Initial commit: browser OSM city generator
This commit is contained in:
commit
2d4bad0dc7
32 files changed
+5915
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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()));
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
};
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user