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() });
|
||||
}
|
||||
};
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
import { AreaPicker, bboxSizeKm } from './view/picker';
|
||||
import { Viewer } from './view/viewer';
|
||||
import { fetchOverpass, geocode, type BBox, type OsmElement } from './osm/overpass';
|
||||
import { generateWorld } from './gen/pipeline';
|
||||
import { downloadWorld, hasWorld, loadWorld, readJsonFile, saveWorld } from './store';
|
||||
import type { World } from './types';
|
||||
|
||||
const MAX_KM2 = 6;
|
||||
const WARN_KM2 = 2.5;
|
||||
|
||||
const app = document.getElementById('app')!;
|
||||
let viewer: Viewer | null = null;
|
||||
let picker: AreaPicker | null = null;
|
||||
|
||||
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]!));
|
||||
|
||||
// ------------------------------------------------------------------ прогресс
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'overlay';
|
||||
overlay.hidden = true;
|
||||
overlay.innerHTML = `<div class="card progress-card"><div class="p-title">Генерация</div><div class="p-text"></div><div class="bar"><i></i></div><button class="btn" data-a="cancel">Отмена</button></div>`;
|
||||
document.body.appendChild(overlay);
|
||||
let abort: AbortController | null = null;
|
||||
overlay.querySelector('[data-a=cancel]')!.addEventListener('click', () => abort?.abort());
|
||||
function progress(text: string, frac: number) {
|
||||
overlay.hidden = false;
|
||||
overlay.querySelector('.p-text')!.textContent = text;
|
||||
(overlay.querySelector('.bar i') as HTMLElement).style.width = `${Math.round(frac * 100)}%`;
|
||||
}
|
||||
const hideProgress = () => (overlay.hidden = true);
|
||||
|
||||
// ------------------------------------------------------------------ экраны
|
||||
|
||||
function showViewer(world: World) {
|
||||
picker = null;
|
||||
app.innerHTML = '';
|
||||
viewer?.destroy();
|
||||
viewer = new Viewer(app, world, {
|
||||
onNew: () => showPicker(world.bbox),
|
||||
onExport: () => downloadWorld(world),
|
||||
});
|
||||
(window as any).viewer = viewer;
|
||||
}
|
||||
|
||||
async function showPicker(initial?: BBox) {
|
||||
viewer?.destroy();
|
||||
viewer = null;
|
||||
app.innerHTML = '';
|
||||
const stage = document.createElement('div');
|
||||
stage.className = 'picker-stage';
|
||||
app.appendChild(stage);
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'picker-panel card';
|
||||
panel.innerHTML = `
|
||||
<div class="brand">Город сверху</div>
|
||||
<p class="lead">Выделите на карте небольшой город или район — по данным OpenStreetMap будет построена подробная карта с планировками и мебелью во всех зданиях.</p>
|
||||
<form class="search"><input type="search" placeholder="Найти место: город, район, улица…" autocomplete="off"><button class="btn">Найти</button></form>
|
||||
<div class="results"></div>
|
||||
<div class="row">
|
||||
<button class="btn" data-a="select">▭ Выделить область</button>
|
||||
<button class="btn ghost" data-a="quick">1×1 км в центре</button>
|
||||
</div>
|
||||
<div class="hint">Или перетаскивайте с зажатым Shift.</div>
|
||||
<div class="sel-info">Область не выбрана</div>
|
||||
<label class="field">Название <input type="text" data-f="name" placeholder="например, Балцерс"></label>
|
||||
<button class="btn primary" data-a="go" disabled>Сгенерировать</button>
|
||||
<details class="more"><summary>Другие способы</summary>
|
||||
<div class="col">
|
||||
<button class="btn ghost" data-a="last" hidden>Открыть последний мир</button>
|
||||
<label class="btn ghost file">Открыть мир (.json / .json.gz)<input type="file" accept=".json,.gz" data-f="world" hidden></label>
|
||||
<label class="btn ghost file">Сгенерировать из выгрузки Overpass (.json)<input type="file" accept=".json,.gz" data-f="osm" hidden></label>
|
||||
<div class="hint">Выгрузка Overpass должна быть в формате <code>[out:json] … out geom;</code>. Область берётся из выделения или из данных.</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="err" hidden></div>`;
|
||||
stage.appendChild(panel);
|
||||
const mapBox = document.createElement('div');
|
||||
mapBox.className = 'map-box';
|
||||
stage.appendChild(mapBox);
|
||||
mapBox.insertAdjacentHTML('beforeend', '<div class="attribution">Карта © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors</div>');
|
||||
|
||||
const $ = <T extends Element>(s: string) => panel.querySelector(s) as T;
|
||||
const nameInput = $<HTMLInputElement>('[data-f=name]');
|
||||
const goBtn = $<HTMLButtonElement>('[data-a=go]');
|
||||
const selBtn = $<HTMLButtonElement>('[data-a=select]');
|
||||
const err = $<HTMLDivElement>('.err');
|
||||
const showErr = (m: string | null) => { err.hidden = !m; err.textContent = m ?? ''; };
|
||||
|
||||
let bbox: BBox | null = null;
|
||||
picker = new AreaPicker(mapBox, (b) => {
|
||||
bbox = b;
|
||||
const info = $<HTMLDivElement>('.sel-info');
|
||||
if (!b) {
|
||||
info.textContent = 'Область не выбрана';
|
||||
info.className = 'sel-info';
|
||||
goBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
const s = bboxSizeKm(b);
|
||||
info.textContent = `${s.w.toFixed(2)} × ${s.h.toFixed(2)} км · ${s.km2.toFixed(2)} км²` + (s.km2 > MAX_KM2 ? ' — слишком большая область' : s.km2 > WARN_KM2 ? ' — большая область, генерация займёт время' : '');
|
||||
info.className = 'sel-info' + (s.km2 > MAX_KM2 ? ' bad' : s.km2 > WARN_KM2 ? ' warn' : ' ok');
|
||||
goBtn.disabled = s.km2 > MAX_KM2;
|
||||
});
|
||||
if (initial) picker.setSelection(initial);
|
||||
else picker.fit([47.06, 9.49, 47.075, 9.52]);
|
||||
|
||||
selBtn.addEventListener('click', () => {
|
||||
picker!.selectMode = !picker!.selectMode;
|
||||
selBtn.classList.toggle('on', picker!.selectMode);
|
||||
});
|
||||
$<HTMLButtonElement>('[data-a=quick]').addEventListener('click', () => picker!.selectAroundCenter(1));
|
||||
|
||||
const form = $<HTMLFormElement>('.search');
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const q = (form.querySelector('input') as HTMLInputElement).value.trim();
|
||||
if (!q) return;
|
||||
const box = $<HTMLDivElement>('.results');
|
||||
box.innerHTML = '<div class="hint">Ищу…</div>';
|
||||
try {
|
||||
const res = await geocode(q);
|
||||
box.innerHTML = res.length ? '' : '<div class="hint">Ничего не найдено</div>';
|
||||
for (const r of res) {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'result';
|
||||
b.textContent = r.name;
|
||||
b.addEventListener('click', () => {
|
||||
const s = bboxSizeKm(r.bbox);
|
||||
picker!.fit(r.bbox);
|
||||
if (s.km2 <= MAX_KM2 && s.km2 > 0.02) picker!.setSelection(r.bbox, false);
|
||||
else {
|
||||
picker!.fit([r.lat - 0.01, r.lon - 0.015, r.lat + 0.01, r.lon + 0.015]);
|
||||
picker!.selectAroundCenter(1.2);
|
||||
}
|
||||
nameInput.value = r.name.split(',')[0];
|
||||
box.innerHTML = '';
|
||||
});
|
||||
box.appendChild(b);
|
||||
}
|
||||
} catch (ex) {
|
||||
box.innerHTML = `<div class="hint">${esc((ex as Error).message)}</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
goBtn.addEventListener('click', async () => {
|
||||
if (!bbox) return;
|
||||
showErr(null);
|
||||
abort = new AbortController();
|
||||
try {
|
||||
progress('Загрузка данных OpenStreetMap…', 0.02);
|
||||
const els = await fetchOverpass(bbox, (s) => progress(s, 0.03), abort.signal);
|
||||
await runGeneration(els, bbox, nameInput.value.trim() || 'Новая карта');
|
||||
} catch (ex) {
|
||||
hideProgress();
|
||||
if ((ex as Error).name !== 'AbortError') showErr((ex as Error).message);
|
||||
}
|
||||
});
|
||||
|
||||
const lastBtn = $<HTMLButtonElement>('[data-a=last]');
|
||||
if (await hasWorld()) lastBtn.hidden = false;
|
||||
lastBtn.addEventListener('click', async () => {
|
||||
progress('Загрузка сохранённого мира…', 0.5);
|
||||
const w = await loadWorld();
|
||||
hideProgress();
|
||||
if (w) showViewer(w);
|
||||
});
|
||||
$<HTMLInputElement>('[data-f=world]').addEventListener('change', async (e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!f) return;
|
||||
try {
|
||||
progress('Чтение файла…', 0.4);
|
||||
const w = (await readJsonFile(f)) as World;
|
||||
if (!w.buildings || !w.bounds) throw new Error('Это не файл мира');
|
||||
hideProgress();
|
||||
showViewer(w);
|
||||
} catch (ex) {
|
||||
hideProgress();
|
||||
showErr((ex as Error).message);
|
||||
}
|
||||
});
|
||||
$<HTMLInputElement>('[data-f=osm]').addEventListener('change', async (e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!f) return;
|
||||
try {
|
||||
progress('Чтение выгрузки…', 0.03);
|
||||
const data = await readJsonFile(f);
|
||||
const els: OsmElement[] = data.elements;
|
||||
if (!Array.isArray(els)) throw new Error('В файле нет поля elements');
|
||||
const b = bbox ?? bboxOfElements(els);
|
||||
abort = new AbortController();
|
||||
await runGeneration(els, b, nameInput.value.trim() || f.name.replace(/\.json.*$/, ''));
|
||||
} catch (ex) {
|
||||
hideProgress();
|
||||
showErr((ex as Error).message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bboxOfElements(els: OsmElement[]): BBox {
|
||||
let s = 90, w = 180, n = -90, e = -180;
|
||||
for (const el of els)
|
||||
if (el.type === 'node' && el.lat != null && el.lon != null) {
|
||||
s = Math.min(s, el.lat); n = Math.max(n, el.lat); w = Math.min(w, el.lon); e = Math.max(e, el.lon);
|
||||
} else if (el.geometry) for (const g of el.geometry) { s = Math.min(s, g.lat); n = Math.max(n, g.lat); w = Math.min(w, g.lon); e = Math.max(e, g.lon); }
|
||||
return [s, w, n, e];
|
||||
}
|
||||
|
||||
async function runGeneration(els: OsmElement[], bbox: BBox, name: string) {
|
||||
// постоянный сид: один и тот же город всегда генерируется одинаково (сид здания = OSM-id ⊕ сид мира)
|
||||
const seed = 1;
|
||||
const t0 = performance.now();
|
||||
const world = await generateWorld(els, bbox, name, seed, progress, abort?.signal);
|
||||
console.info(`world generated in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
|
||||
progress('Сохранение…', 1);
|
||||
try {
|
||||
await saveWorld(world);
|
||||
} catch (e) {
|
||||
console.warn('save failed', e);
|
||||
}
|
||||
hideProgress();
|
||||
showViewer(world);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ старт
|
||||
|
||||
(async () => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.has('last') && (await hasWorld())) {
|
||||
const w = await loadWorld();
|
||||
if (w) return showViewer(w);
|
||||
}
|
||||
showPicker();
|
||||
})();
|
||||
|
||||
// для отладки из консоли
|
||||
(window as any).citygen = { runGeneration, showViewer };
|
||||
@@ -0,0 +1,105 @@
|
||||
// Загрузка данных OSM через Overpass API (вызывается прямо из браузера, CORS разрешён).
|
||||
// Для публичной игры с множеством игроков нужно своё зеркало или статическая нарезка —
|
||||
// публичные серверы Overpass рассчитаны на умеренную нагрузку.
|
||||
|
||||
export type BBox = [number, number, number, number]; // south, west, north, east
|
||||
|
||||
export interface OsmElement {
|
||||
type: 'node' | 'way' | 'relation';
|
||||
id: number;
|
||||
tags?: Record<string, string>;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
geometry?: { lat: number; lon: number }[];
|
||||
members?: { type: string; ref: number; role: string; geometry?: { lat: number; lon: number }[] }[];
|
||||
}
|
||||
|
||||
export const OVERPASS_ENDPOINTS = [
|
||||
'https://overpass-api.de/api/interpreter',
|
||||
'https://overpass.private.coffee/api/interpreter',
|
||||
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
|
||||
];
|
||||
|
||||
export function buildQuery(b: BBox): string {
|
||||
const bb = `(${b[0]},${b[1]},${b[2]},${b[3]})`;
|
||||
return `[out:json][timeout:120];
|
||||
(
|
||||
way["building"]${bb};
|
||||
relation["building"]["type"="multipolygon"]${bb};
|
||||
way["highway"]${bb};
|
||||
way["railway"~"^(rail|tram|light_rail|narrow_gauge|subway|platform)$"]${bb};
|
||||
way["waterway"]${bb};
|
||||
way["landuse"]${bb};
|
||||
relation["landuse"]${bb};
|
||||
way["natural"]${bb};
|
||||
relation["natural"]${bb};
|
||||
way["leisure"]${bb};
|
||||
relation["leisure"]${bb};
|
||||
way["amenity"]${bb};
|
||||
relation["amenity"]${bb};
|
||||
way["place"~"square"]${bb};
|
||||
way["area:highway"]${bb};
|
||||
node["natural"="tree"]${bb};
|
||||
node["shop"]${bb};
|
||||
node["amenity"]${bb};
|
||||
node["office"]${bb};
|
||||
node["craft"]${bb};
|
||||
node["tourism"]${bb};
|
||||
node["healthcare"]${bb};
|
||||
node["highway"="street_lamp"]${bb};
|
||||
);
|
||||
out geom;`;
|
||||
}
|
||||
|
||||
export async function fetchOverpass(b: BBox, onStatus?: (s: string) => void, signal?: AbortSignal): Promise<OsmElement[]> {
|
||||
const query = buildQuery(b);
|
||||
let lastErr: unknown = null;
|
||||
for (const url of OVERPASS_ENDPOINTS) {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
onStatus?.(`Запрос к ${new URL(url).host}${attempt ? ' (повтор)' : ''}…`);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: 'data=' + encodeURIComponent(query),
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
signal,
|
||||
});
|
||||
if (res.status === 429 || res.status === 504) {
|
||||
lastErr = new Error(`Сервер перегружен (${res.status})`);
|
||||
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
if (json.remark && /error|timed out|runtime/i.test(json.remark)) throw new Error(json.remark);
|
||||
return json.elements as OsmElement[];
|
||||
} catch (e) {
|
||||
if (signal?.aborted) throw e;
|
||||
lastErr = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('Не удалось загрузить данные OSM: ' + (lastErr instanceof Error ? lastErr.message : String(lastErr)));
|
||||
}
|
||||
|
||||
export interface GeocodeResult {
|
||||
name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
bbox: BBox;
|
||||
}
|
||||
|
||||
/** Поиск места через Nominatim (не чаще раза в секунду по правилам сервиса). */
|
||||
export async function geocode(q: string): Promise<GeocodeResult[]> {
|
||||
const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=6&accept-language=ru&q=${encodeURIComponent(q)}`;
|
||||
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('Поиск недоступен: HTTP ' + res.status);
|
||||
const arr = await res.json();
|
||||
return arr.map((r: any) => ({
|
||||
name: r.display_name,
|
||||
lat: +r.lat,
|
||||
lon: +r.lon,
|
||||
bbox: [+r.boundingbox[0], +r.boundingbox[2], +r.boundingbox[1], +r.boundingbox[3]] as BBox,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Хранение миров в IndexedDB (локально в браузере).
|
||||
import type { World } from './types';
|
||||
|
||||
const DB = 'citygen';
|
||||
const STORE = 'worlds';
|
||||
|
||||
function open(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB, 1);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function tx<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
|
||||
const db = await open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = db.transaction(STORE, mode);
|
||||
const req = fn(t.objectStore(STORE));
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export const saveWorld = (w: World) => tx('readwrite', (s) => s.put(w, 'last'));
|
||||
export const loadWorld = () => tx<World | undefined>('readonly', (s) => s.get('last'));
|
||||
export const hasWorld = async () => {
|
||||
try {
|
||||
return (await tx<number>('readonly', (s) => s.count('last'))) > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Скачать мир файлом (.json.gz, если браузер умеет сжимать). */
|
||||
export async function downloadWorld(w: World) {
|
||||
const json = JSON.stringify(w);
|
||||
let blob: Blob = new Blob([json], { type: 'application/json' });
|
||||
let ext = 'json';
|
||||
if ('CompressionStream' in window) {
|
||||
const cs = blob.stream().pipeThrough(new CompressionStream('gzip'));
|
||||
blob = await new Response(cs).blob();
|
||||
ext = 'json.gz';
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${(w.name || 'world').replace(/[^\p{L}\p{N}_-]+/gu, '_').slice(0, 40)}.${ext}`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
|
||||
}
|
||||
|
||||
/** Прочитать файл мира или Overpass JSON (поддерживает .gz). */
|
||||
export async function readJsonFile(file: File): Promise<any> {
|
||||
let stream: ReadableStream = file.stream();
|
||||
const head = new Uint8Array(await file.slice(0, 2).arrayBuffer());
|
||||
if (head[0] === 0x1f && head[1] === 0x8b) stream = stream.pipeThrough(new DecompressionStream('gzip'));
|
||||
return JSON.parse(await new Response(stream).text());
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
// Модель мира. Все координаты в метрах в локальной проекции мира:
|
||||
// x — на восток, y — на юг (как на экране). Углы в радианах.
|
||||
// Модель с самого начала рассчитана на будущую симуляцию: у всего есть id,
|
||||
// двери и лестницы — это связи между комнатами (граф навигации),
|
||||
// у объектов есть флаги «блокирует проход / блокирует обзор».
|
||||
|
||||
export type Vec = [number, number];
|
||||
export type Ring = Vec[];
|
||||
|
||||
export interface World {
|
||||
version: 1;
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
seed: number;
|
||||
/** Центр проекции и исходная область в градусах [south, west, north, east]. */
|
||||
origin: { lat: number; lon: number };
|
||||
bbox: [number, number, number, number];
|
||||
/** Границы области в метрах. */
|
||||
bounds: { minX: number; minY: number; maxX: number; maxY: number };
|
||||
areas: Area[];
|
||||
roads: Road[];
|
||||
buildings: Building[];
|
||||
/** Уличные объекты: деревья, машины, скамейки, фонари. */
|
||||
props: Prop[];
|
||||
/** Библиотека общих моделей предметов: prop.model → набор фигур. */
|
||||
models: Record<string, Shape[]>;
|
||||
attribution: string;
|
||||
}
|
||||
|
||||
export type AreaKind =
|
||||
| 'residential' | 'commercial' | 'industrial' | 'retail' | 'farmland' | 'farmyard'
|
||||
| 'grass' | 'park' | 'forest' | 'scrub' | 'meadow' | 'orchard' | 'vineyard'
|
||||
| 'water' | 'wetland' | 'beach' | 'sand' | 'parking' | 'pitch' | 'playground'
|
||||
| 'cemetery' | 'construction' | 'railway' | 'school' | 'hospital' | 'pedestrian' | 'garden';
|
||||
|
||||
export interface Area {
|
||||
id: string;
|
||||
kind: AreaKind;
|
||||
/** Первое кольцо — внешнее, остальные — дыры. */
|
||||
rings: Ring[];
|
||||
osmId?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export type RoadKind =
|
||||
| 'motorway' | 'trunk' | 'primary' | 'secondary' | 'tertiary' | 'residential'
|
||||
| 'unclassified' | 'service' | 'living_street' | 'pedestrian' | 'track'
|
||||
| 'footway' | 'cycleway' | 'path' | 'steps' | 'rail' | 'tram' | 'river' | 'stream' | 'canal' | 'ditch';
|
||||
|
||||
export interface Road {
|
||||
id: string;
|
||||
kind: RoadKind;
|
||||
pts: Vec[];
|
||||
/** Ширина проезжей части (или русла) в метрах. */
|
||||
width: number;
|
||||
/** Ширина тротуара с каждой стороны, 0 — нет. */
|
||||
sidewalk: number;
|
||||
lanes: number;
|
||||
oneway: boolean;
|
||||
layer: number;
|
||||
bridge: boolean;
|
||||
tunnel: boolean;
|
||||
name?: string;
|
||||
osmId?: string;
|
||||
}
|
||||
|
||||
export type BuildingKind =
|
||||
| 'house' | 'apartments' | 'mixed' | 'retail' | 'restaurant' | 'office' | 'industrial'
|
||||
| 'warehouse' | 'school' | 'hospital' | 'hotel' | 'church' | 'civic' | 'garage' | 'shed' | 'farm';
|
||||
|
||||
export interface Building {
|
||||
id: string;
|
||||
osmId?: string;
|
||||
kind: BuildingKind;
|
||||
/** Подтип из OSM-тегов/POI: 'supermarket', 'bakery', 'pharmacy'… */
|
||||
use?: string;
|
||||
name?: string;
|
||||
address?: string;
|
||||
footprint: Ring;
|
||||
/** Внутренние дворы (дыры в контуре). */
|
||||
holes: Ring[];
|
||||
/** Площадь контура, м². */
|
||||
area: number;
|
||||
levels: number;
|
||||
/** Откуда взялась этажность: 'osm' | 'height' | 'estimate'. */
|
||||
levelsSource: 'osm' | 'height' | 'estimate';
|
||||
/** Локальная система здания: поворот сетки планировки. */
|
||||
frame: { origin: Vec; angle: number };
|
||||
floors: Floor[];
|
||||
/** Помещения-юниты: квартиры, магазины, офисы — будущие «домохозяйства» и «рабочие места». */
|
||||
units: Unit[];
|
||||
/** Сколько спальных мест (оценка жильцов). */
|
||||
beds: number;
|
||||
seed: number;
|
||||
}
|
||||
|
||||
export interface Unit {
|
||||
id: string;
|
||||
kind: 'home' | 'shop' | 'office' | 'service' | 'common';
|
||||
level: number;
|
||||
roomIds: string[];
|
||||
beds: number;
|
||||
}
|
||||
|
||||
export type RoomKind =
|
||||
| 'hall' | 'landing' | 'living' | 'kitchen' | 'bedroom' | 'bath' | 'wc' | 'storage' | 'corridor'
|
||||
| 'stairs' | 'lift' | 'lobby' | 'sales' | 'backroom' | 'office' | 'openoffice' | 'meeting'
|
||||
| 'dining' | 'prokitchen' | 'workshop' | 'hall_ind' | 'classroom' | 'staff' | 'ward'
|
||||
| 'guestroom' | 'nave' | 'garage' | 'utility' | 'void';
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
kind: RoomKind;
|
||||
/** Контур комнаты (может быть несколько колец; дыры — по чётности). Рисовать с клипом по контуру здания. */
|
||||
rings: Ring[];
|
||||
area: number;
|
||||
labelPos: Vec;
|
||||
unitId?: string;
|
||||
}
|
||||
|
||||
export interface Wall {
|
||||
a: Vec;
|
||||
b: Vec;
|
||||
thickness: number;
|
||||
exterior: boolean;
|
||||
}
|
||||
|
||||
export interface Door {
|
||||
id: string;
|
||||
a: Vec;
|
||||
b: Vec;
|
||||
/** Комнаты по обе стороны; 'outside' — улица. Это ребро графа навигации. */
|
||||
rooms: [string, string];
|
||||
/** 'opening' — проём без полотна (арка, выход на лестницу). */
|
||||
kind: 'interior' | 'entrance' | 'unit' | 'garage' | 'opening';
|
||||
/** Сторона, куда открывается полотно (точка внутри комнаты). */
|
||||
swingTo: Vec;
|
||||
}
|
||||
|
||||
export interface Window {
|
||||
a: Vec;
|
||||
b: Vec;
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
export interface Stairs {
|
||||
id: string;
|
||||
rings: Ring[];
|
||||
/** Ось марша: от низа к верху. */
|
||||
from: Vec;
|
||||
to: Vec;
|
||||
width: number;
|
||||
up: boolean;
|
||||
down: boolean;
|
||||
lift?: boolean;
|
||||
/** id комнаты-лестничной клетки на этом этаже. */
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
export interface Floor {
|
||||
level: number;
|
||||
rooms: Room[];
|
||||
walls: Wall[];
|
||||
doors: Door[];
|
||||
windows: Window[];
|
||||
stairs: Stairs[];
|
||||
props: Prop[];
|
||||
}
|
||||
|
||||
/** Примитив в локальных координатах предмета (центр предмета в 0,0; «спинка» к стене при y<0). */
|
||||
export type Shape =
|
||||
| { t: 'rect'; x: number; y: number; w: number; h: number; r?: number; s?: ShapeStyle }
|
||||
| { t: 'circle'; x: number; y: number; r: number; s?: ShapeStyle }
|
||||
| { t: 'line'; x1: number; y1: number; x2: number; y2: number; s?: ShapeStyle };
|
||||
|
||||
/** Стиль: 'base' — основной предмет, 'soft' — ткань/подушка, 'dark' — техника/акцент, 'green' — растение, 'glass', 'metal'. */
|
||||
export type ShapeStyle = 'base' | 'soft' | 'dark' | 'green' | 'glass' | 'metal' | 'wood' | 'water' | 'line' | 'car' | 'fabric' | 'accent';
|
||||
|
||||
export interface Prop {
|
||||
id: string;
|
||||
kind: string;
|
||||
pos: Vec;
|
||||
rot: number;
|
||||
w: number;
|
||||
d: number;
|
||||
/** Фигуры предмета; после сборки мира обычно вынесены в world.models (см. model). */
|
||||
shapes?: Shape[];
|
||||
model?: string;
|
||||
blocksMove: boolean;
|
||||
blocksSight: boolean;
|
||||
roomId?: string;
|
||||
/** Цвет для машин и т.п. (иначе по стилю). */
|
||||
color?: string;
|
||||
/** Для симуляции: можно сидеть/спать/хранить и т.п. */
|
||||
tags?: string[];
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { Ring, Vec } from '../types';
|
||||
|
||||
export const add = (a: Vec, b: Vec): Vec => [a[0] + b[0], a[1] + b[1]];
|
||||
export const sub = (a: Vec, b: Vec): Vec => [a[0] - b[0], a[1] - b[1]];
|
||||
export const mul = (a: Vec, k: number): Vec => [a[0] * k, a[1] * k];
|
||||
export const len = (a: Vec): number => Math.hypot(a[0], a[1]);
|
||||
export const dist = (a: Vec, b: Vec): number => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||
export const lerp = (a: Vec, b: Vec, t: number): Vec => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
|
||||
export function rotate(p: Vec, ang: number): Vec {
|
||||
const c = Math.cos(ang), s = Math.sin(ang);
|
||||
return [p[0] * c - p[1] * s, p[0] * s + p[1] * c];
|
||||
}
|
||||
|
||||
/** Знаковая площадь (положительная при обходе по часовой в экранных осях y-вниз). */
|
||||
export function signedArea(r: Ring): number {
|
||||
let a = 0;
|
||||
for (let i = 0, n = r.length; i < n; i++) {
|
||||
const p = r[i], q = r[(i + 1) % n];
|
||||
a += p[0] * q[1] - q[0] * p[1];
|
||||
}
|
||||
return a / 2;
|
||||
}
|
||||
|
||||
export const ringArea = (r: Ring): number => Math.abs(signedArea(r));
|
||||
|
||||
export function centroid(r: Ring): Vec {
|
||||
let a = 0, cx = 0, cy = 0;
|
||||
for (let i = 0, n = r.length; i < n; i++) {
|
||||
const p = r[i], q = r[(i + 1) % n];
|
||||
const f = p[0] * q[1] - q[0] * p[1];
|
||||
a += f;
|
||||
cx += (p[0] + q[0]) * f;
|
||||
cy += (p[1] + q[1]) * f;
|
||||
}
|
||||
if (Math.abs(a) < 1e-9) {
|
||||
const s = r.reduce((acc, p) => add(acc, p), [0, 0] as Vec);
|
||||
return mul(s, 1 / r.length);
|
||||
}
|
||||
return [cx / (3 * a), cy / (3 * a)];
|
||||
}
|
||||
|
||||
export function pointInRing(p: Vec, r: Ring): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
|
||||
const xi = r[i][0], yi = r[i][1], xj = r[j][0], yj = r[j][1];
|
||||
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
export function pointInRings(p: Vec, rings: Ring[]): boolean {
|
||||
let inside = false;
|
||||
for (const r of rings) if (pointInRing(p, r)) inside = !inside;
|
||||
return inside;
|
||||
}
|
||||
|
||||
export function distToSegment(p: Vec, a: Vec, b: Vec): number {
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const l2 = dx * dx + dy * dy;
|
||||
let t = l2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2 : 0;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
|
||||
}
|
||||
|
||||
export function closestOnSegment(p: Vec, a: Vec, b: Vec): Vec {
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const l2 = dx * dx + dy * dy;
|
||||
let t = l2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2 : 0;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return [a[0] + t * dx, a[1] + t * dy];
|
||||
}
|
||||
|
||||
export function distToRingEdge(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;
|
||||
}
|
||||
|
||||
export function bboxOf(pts: Vec[]): [number, number, number, number] {
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (const p of pts) {
|
||||
if (p[0] < x0) x0 = p[0];
|
||||
if (p[1] < y0) y0 = p[1];
|
||||
if (p[0] > x1) x1 = p[0];
|
||||
if (p[1] > y1) y1 = p[1];
|
||||
}
|
||||
return [x0, y0, x1, y1];
|
||||
}
|
||||
|
||||
/** Убрать замыкающую точку, дубликаты и почти-коллинеарные вершины. */
|
||||
export function cleanRing(r: Ring, eps = 0.05): Ring {
|
||||
let pts = r.slice();
|
||||
if (pts.length > 1 && dist(pts[0], pts[pts.length - 1]) < 1e-6) pts.pop();
|
||||
pts = pts.filter((p, i) => dist(p, pts[(i + 1) % pts.length]) > eps);
|
||||
let changed = true;
|
||||
while (changed && pts.length > 3) {
|
||||
changed = false;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const a = pts[(i - 1 + pts.length) % pts.length], b = pts[i], c = pts[(i + 1) % pts.length];
|
||||
if (distToSegment(b, a, c) < eps) {
|
||||
pts.splice(i, 1);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/** Выпуклая оболочка (монотонная цепь). */
|
||||
export function convexHull(points: Vec[]): Vec[] {
|
||||
const pts = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
if (pts.length < 3) return pts;
|
||||
const cross = (o: Vec, a: Vec, b: Vec) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
const lower: Vec[] = [];
|
||||
for (const p of pts) {
|
||||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
|
||||
lower.push(p);
|
||||
}
|
||||
const upper: Vec[] = [];
|
||||
for (let i = pts.length - 1; i >= 0; i--) {
|
||||
const p = pts[i];
|
||||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
|
||||
upper.push(p);
|
||||
}
|
||||
upper.pop();
|
||||
lower.pop();
|
||||
return lower.concat(upper);
|
||||
}
|
||||
|
||||
/** Угол прямоугольника минимальной площади, описанного вокруг контура (ориентация здания). */
|
||||
export function minAreaRectAngle(r: Ring): number {
|
||||
const hull = convexHull(r);
|
||||
let best = Infinity, bestAng = 0;
|
||||
for (let i = 0; i < hull.length; i++) {
|
||||
const a = hull[i], b = hull[(i + 1) % hull.length];
|
||||
const ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
|
||||
const c = Math.cos(-ang), s = Math.sin(-ang);
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (const p of hull) {
|
||||
const x = p[0] * c - p[1] * s, y = p[0] * s + p[1] * c;
|
||||
x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y);
|
||||
}
|
||||
const area = (x1 - x0) * (y1 - y0);
|
||||
if (area < best - 1e-6) {
|
||||
best = area;
|
||||
// ориентируем так, чтобы длинная сторона шла по x
|
||||
bestAng = x1 - x0 >= y1 - y0 ? ang : ang + Math.PI / 2;
|
||||
}
|
||||
}
|
||||
// нормируем в (-pi/2, pi/2]
|
||||
while (bestAng > Math.PI / 2) bestAng -= Math.PI;
|
||||
while (bestAng <= -Math.PI / 2) bestAng += Math.PI;
|
||||
return bestAng;
|
||||
}
|
||||
|
||||
export function polylineLength(pts: Vec[]): number {
|
||||
let l = 0;
|
||||
for (let i = 1; i < pts.length; i++) l += dist(pts[i - 1], pts[i]);
|
||||
return l;
|
||||
}
|
||||
|
||||
/** Пересекаются ли два bbox. */
|
||||
export const bboxHit = (a: number[], b: number[]) => a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1];
|
||||
@@ -0,0 +1,43 @@
|
||||
// Детерминированный ГПСЧ: одно и то же здание с тем же сидом всегда генерируется одинаково.
|
||||
|
||||
export function hashString(s: string): number {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
export class Rng {
|
||||
private s: number;
|
||||
constructor(seed: number) {
|
||||
this.s = (seed >>> 0) || 0x9e3779b9;
|
||||
}
|
||||
next(): number {
|
||||
// mulberry32
|
||||
let t = (this.s = (this.s + 0x6d2b79f5) >>> 0);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
range(a: number, b: number): number {
|
||||
return a + (b - a) * this.next();
|
||||
}
|
||||
int(a: number, b: number): number {
|
||||
return Math.floor(this.range(a, b + 1));
|
||||
}
|
||||
chance(p: number): boolean {
|
||||
return this.next() < p;
|
||||
}
|
||||
pick<T>(arr: readonly T[]): T {
|
||||
return arr[Math.floor(this.next() * arr.length)];
|
||||
}
|
||||
shuffle<T>(arr: T[]): T[] {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(this.next() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Простая равномерная сетка для поиска по bbox. */
|
||||
export class SpatialGrid<T> {
|
||||
private cells = new Map<number, T[]>();
|
||||
constructor(private size = 50) {}
|
||||
private key(ix: number, iy: number) {
|
||||
return (ix + 32768) * 65536 + (iy + 32768);
|
||||
}
|
||||
insert(item: T, bb: [number, number, number, number]) {
|
||||
const s = this.size;
|
||||
for (let ix = Math.floor(bb[0] / s); ix <= Math.floor(bb[2] / s); ix++)
|
||||
for (let iy = Math.floor(bb[1] / s); iy <= Math.floor(bb[3] / s); iy++) {
|
||||
const k = this.key(ix, iy);
|
||||
let arr = this.cells.get(k);
|
||||
if (!arr) this.cells.set(k, (arr = []));
|
||||
arr.push(item);
|
||||
}
|
||||
}
|
||||
query(bb: [number, number, number, number]): T[] {
|
||||
const s = this.size;
|
||||
const out = new Set<T>();
|
||||
for (let ix = Math.floor(bb[0] / s); ix <= Math.floor(bb[2] / s); ix++)
|
||||
for (let iy = Math.floor(bb[1] / s); iy <= Math.floor(bb[3] / s); iy++) {
|
||||
const arr = this.cells.get(this.key(ix, iy));
|
||||
if (arr) for (const it of arr) out.add(it);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// Мини-карта с тайлами OSM для выбора области (без внешних библиотек).
|
||||
|
||||
import type { BBox } from '../osm/overpass';
|
||||
|
||||
const TILE = 256;
|
||||
const TILE_URL = (z: number, x: number, y: number) => `https://tile.openstreetmap.org/${z}/${x}/${y}.png`;
|
||||
|
||||
const lon2x = (lon: number, z: number) => ((lon + 180) / 360) * TILE * 2 ** z;
|
||||
const lat2y = (lat: number, z: number) => {
|
||||
const s = Math.sin((lat * Math.PI) / 180);
|
||||
return (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * TILE * 2 ** z;
|
||||
};
|
||||
const x2lon = (x: number, z: number) => (x / (TILE * 2 ** z)) * 360 - 180;
|
||||
const y2lat = (y: number, z: number) => {
|
||||
const n = Math.PI - (2 * Math.PI * y) / (TILE * 2 ** z);
|
||||
return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
|
||||
};
|
||||
|
||||
export function bboxSizeKm(b: BBox): { w: number; h: number; km2: number } {
|
||||
const lat = ((b[0] + b[2]) / 2) * (Math.PI / 180);
|
||||
const w = (b[3] - b[1]) * 111.32 * Math.cos(lat);
|
||||
const h = (b[2] - b[0]) * 110.57;
|
||||
return { w, h, km2: w * h };
|
||||
}
|
||||
|
||||
export class AreaPicker {
|
||||
readonly canvas = document.createElement('canvas');
|
||||
private ctx = this.canvas.getContext('2d')!;
|
||||
private lat = 55.751;
|
||||
private lon = 37.618;
|
||||
private zoom = 12;
|
||||
private tiles = new Map<string, HTMLImageElement>();
|
||||
private drag: { x: number; y: number; lat: number; lon: number } | null = null;
|
||||
private sel: { a: [number, number]; b: [number, number] } | null = null; // lat, lon
|
||||
private selecting = false;
|
||||
selectMode = false;
|
||||
private raf = 0;
|
||||
private pointers = new Map<number, { x: number; y: number }>();
|
||||
private pinch: { d: number; zoom: number } | null = null;
|
||||
|
||||
constructor(private parent: HTMLElement, private onChange: (b: BBox | null) => void) {
|
||||
this.canvas.className = 'map-canvas';
|
||||
parent.appendChild(this.canvas);
|
||||
new ResizeObserver(() => this.resize()).observe(parent);
|
||||
this.resize();
|
||||
this.canvas.addEventListener('pointerdown', (e) => this.down(e));
|
||||
this.canvas.addEventListener('pointermove', (e) => this.move(e));
|
||||
this.canvas.addEventListener('pointerup', (e) => this.up(e));
|
||||
this.canvas.addEventListener('pointercancel', (e) => this.up(e));
|
||||
this.canvas.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
this.zoomAt(e.offsetX, e.offsetY, -Math.sign(e.deltaY) * (Math.abs(e.deltaY) > 50 ? 0.5 : 0.25));
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
private resize() {
|
||||
const r = this.parent.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
this.canvas.width = Math.round(r.width * dpr);
|
||||
this.canvas.height = Math.round(r.height * dpr);
|
||||
this.canvas.style.width = r.width + 'px';
|
||||
this.canvas.style.height = r.height + 'px';
|
||||
this.request();
|
||||
}
|
||||
|
||||
private get W() { return this.canvas.width / (window.devicePixelRatio || 1); }
|
||||
private get H() { return this.canvas.height / (window.devicePixelRatio || 1); }
|
||||
|
||||
private toScreen(lat: number, lon: number): [number, number] {
|
||||
const z = this.zoom;
|
||||
return [lon2x(lon, z) - lon2x(this.lon, z) + this.W / 2, lat2y(lat, z) - lat2y(this.lat, z) + this.H / 2];
|
||||
}
|
||||
private toLatLon(x: number, y: number): [number, number] {
|
||||
const z = this.zoom;
|
||||
return [y2lat(lat2y(this.lat, z) + y - this.H / 2, z), x2lon(lon2x(this.lon, z) + x - this.W / 2, z)];
|
||||
}
|
||||
|
||||
private zoomAt(x: number, y: number, dz: number) {
|
||||
const [lat, lon] = this.toLatLon(x, y);
|
||||
this.zoom = Math.max(2, Math.min(19, this.zoom + dz));
|
||||
// держим точку под курсором на месте
|
||||
const [sx, sy] = this.toScreen(lat, lon);
|
||||
const [clat, clon] = this.toLatLon(this.W / 2 + (sx - x), this.H / 2 + (sy - y));
|
||||
this.lat = clat;
|
||||
this.lon = clon;
|
||||
this.request();
|
||||
}
|
||||
|
||||
private down(e: PointerEvent) {
|
||||
this.canvas.setPointerCapture(e.pointerId);
|
||||
this.pointers.set(e.pointerId, { x: e.offsetX, y: e.offsetY });
|
||||
if (this.pointers.size === 2) {
|
||||
const [p, q] = [...this.pointers.values()];
|
||||
this.pinch = { d: Math.hypot(p.x - q.x, p.y - q.y), zoom: this.zoom };
|
||||
this.drag = null;
|
||||
this.selecting = false;
|
||||
return;
|
||||
}
|
||||
if (this.selectMode || e.shiftKey) {
|
||||
const p = this.toLatLon(e.offsetX, e.offsetY);
|
||||
this.sel = { a: p, b: p };
|
||||
this.selecting = true;
|
||||
} else this.drag = { x: e.offsetX, y: e.offsetY, lat: this.lat, lon: this.lon };
|
||||
}
|
||||
|
||||
private move(e: PointerEvent) {
|
||||
if (this.pointers.has(e.pointerId)) this.pointers.set(e.pointerId, { x: e.offsetX, y: e.offsetY });
|
||||
if (this.pinch && this.pointers.size === 2) {
|
||||
const [p, q] = [...this.pointers.values()];
|
||||
const d = Math.hypot(p.x - q.x, p.y - q.y);
|
||||
const target = this.pinch.zoom + Math.log2(d / this.pinch.d);
|
||||
this.zoomAt((p.x + q.x) / 2, (p.y + q.y) / 2, target - this.zoom);
|
||||
return;
|
||||
}
|
||||
if (this.selecting && this.sel) {
|
||||
this.sel.b = this.toLatLon(e.offsetX, e.offsetY);
|
||||
this.emit();
|
||||
this.request();
|
||||
} else if (this.drag) {
|
||||
const z = this.zoom;
|
||||
const cx = lon2x(this.drag.lon, z) - (e.offsetX - this.drag.x);
|
||||
const cy = lat2y(this.drag.lat, z) - (e.offsetY - this.drag.y);
|
||||
this.lon = x2lon(cx, z);
|
||||
this.lat = y2lat(cy, z);
|
||||
this.request();
|
||||
}
|
||||
}
|
||||
|
||||
private up(e: PointerEvent) {
|
||||
this.pointers.delete(e.pointerId);
|
||||
if (this.pointers.size < 2) this.pinch = null;
|
||||
if (this.selecting) {
|
||||
this.selecting = false;
|
||||
const b = this.bbox();
|
||||
if (b && bboxSizeKm(b).km2 < 0.0004) this.sel = null; // случайный клик
|
||||
this.emit();
|
||||
}
|
||||
this.drag = null;
|
||||
this.request();
|
||||
}
|
||||
|
||||
bbox(): BBox | null {
|
||||
if (!this.sel) return null;
|
||||
const { a, b } = this.sel;
|
||||
return [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[0], b[0]), Math.max(a[1], b[1])];
|
||||
}
|
||||
|
||||
private emit() {
|
||||
this.onChange(this.bbox());
|
||||
}
|
||||
|
||||
setSelection(b: BBox | null, fit = true) {
|
||||
this.sel = b ? { a: [b[0], b[1]], b: [b[2], b[3]] } : null;
|
||||
if (b && fit) this.fit(b);
|
||||
this.emit();
|
||||
this.request();
|
||||
}
|
||||
|
||||
fit(b: BBox) {
|
||||
this.lat = (b[0] + b[2]) / 2;
|
||||
this.lon = (b[1] + b[3]) / 2;
|
||||
for (let z = 18; z >= 2; z -= 0.25) {
|
||||
const w = lon2x(b[3], z) - lon2x(b[1], z);
|
||||
const h = lat2y(b[0], z) - lat2y(b[2], z);
|
||||
if (w < this.W * 0.7 && h < this.H * 0.7) { this.zoom = z; break; }
|
||||
}
|
||||
this.request();
|
||||
}
|
||||
|
||||
center(): [number, number] {
|
||||
return [this.lat, this.lon];
|
||||
}
|
||||
|
||||
/** Сделать выделение вокруг центра экрана заданного размера (км). */
|
||||
selectAroundCenter(km: number) {
|
||||
const dLat = km / 2 / 110.57;
|
||||
const dLon = km / 2 / (111.32 * Math.cos((this.lat * Math.PI) / 180));
|
||||
this.setSelection([this.lat - dLat, this.lon - dLon, this.lat + dLat, this.lon + dLon], true);
|
||||
}
|
||||
|
||||
private request() {
|
||||
if (this.raf) return;
|
||||
this.raf = requestAnimationFrame(() => {
|
||||
this.raf = 0;
|
||||
this.draw();
|
||||
});
|
||||
}
|
||||
|
||||
private tile(z: number, x: number, y: number): HTMLImageElement | null {
|
||||
const n = 2 ** z;
|
||||
if (y < 0 || y >= n) return null;
|
||||
x = ((x % n) + n) % n;
|
||||
const key = `${z}/${x}/${y}`;
|
||||
let img = this.tiles.get(key);
|
||||
if (!img) {
|
||||
img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => this.request();
|
||||
img.src = TILE_URL(z, x, y);
|
||||
this.tiles.set(key, img);
|
||||
if (this.tiles.size > 600) this.tiles.delete(this.tiles.keys().next().value!);
|
||||
}
|
||||
return img.complete && img.naturalWidth ? img : null;
|
||||
}
|
||||
|
||||
private draw() {
|
||||
const ctx = this.ctx;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = this.W, H = this.H;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.fillStyle = '#e8e4dc';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
const tz = Math.round(this.zoom);
|
||||
const scale = 2 ** (this.zoom - tz);
|
||||
const cx = lon2x(this.lon, tz), cy = lat2y(this.lat, tz);
|
||||
const left = cx - W / 2 / scale, top = cy - H / 2 / scale;
|
||||
const x0 = Math.floor(left / TILE), y0 = Math.floor(top / TILE);
|
||||
const x1 = Math.floor((cx + W / 2 / scale) / TILE), y1 = Math.floor((cy + H / 2 / scale) / TILE);
|
||||
for (let ty = y0; ty <= y1; ty++)
|
||||
for (let tx = x0; tx <= x1; tx++) {
|
||||
const sx = (tx * TILE - left) * scale, sy = (ty * TILE - top) * scale;
|
||||
const img = this.tile(tz, tx, ty);
|
||||
if (img) ctx.drawImage(img, sx, sy, TILE * scale + 0.5, TILE * scale + 0.5);
|
||||
else {
|
||||
// пока тайл грузится — родительский
|
||||
const p = this.tile(tz - 1, tx >> 1, ty >> 1);
|
||||
if (p) ctx.drawImage(p, (tx & 1) * 128, (ty & 1) * 128, 128, 128, sx, sy, TILE * scale + 0.5, TILE * scale + 0.5);
|
||||
}
|
||||
}
|
||||
const b = this.bbox();
|
||||
if (b) {
|
||||
const [ax, ay] = this.toScreen(b[2], b[1]);
|
||||
const [bx, by] = this.toScreen(b[0], b[3]);
|
||||
ctx.fillStyle = 'rgba(20,20,20,0.35)';
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, 0, W, H);
|
||||
ctx.rect(ax, ay, bx - ax, by - ay);
|
||||
ctx.fill('evenodd');
|
||||
ctx.strokeStyle = '#e8590c';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.strokeRect(ax, ay, bx - ax, by - ay);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
// Отрисовка мира на Canvas2D. Все геометрии кэшируются в Path2D в мировых координатах
|
||||
// (метры) и рисуются с трансформацией камеры, поэтому кадр — это десятки заливок, а не тысячи.
|
||||
|
||||
import type { Building, Floor, Prop, Road, Shape, Vec, World } from '../types';
|
||||
import { SpatialGrid } from '../util/spatial';
|
||||
import { bboxOf, bboxHit, pointInRings, polylineLength } from '../util/geom';
|
||||
import * as S from './style';
|
||||
|
||||
export interface Camera {
|
||||
x: number;
|
||||
y: number;
|
||||
/** пикселей на метр */
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface ViewOpts {
|
||||
/** null — крыши; иначе номер этажа (0 — первый). */
|
||||
floor: number | null;
|
||||
overrides: Map<string, number | null>;
|
||||
selected?: string | null;
|
||||
furniture: boolean;
|
||||
labels: boolean;
|
||||
}
|
||||
|
||||
interface Layer {
|
||||
order: number;
|
||||
fill: string | null;
|
||||
path: Path2D;
|
||||
}
|
||||
|
||||
interface FloorPaths {
|
||||
rooms: Map<string, Path2D>;
|
||||
wallsExt: Path2D;
|
||||
wallsInt: Path2D;
|
||||
windows: Path2D;
|
||||
doors: Path2D;
|
||||
stairs: Path2D;
|
||||
stairLines: Path2D;
|
||||
props: Layer[];
|
||||
labels: { pos: Vec; text: string; area: number }[];
|
||||
}
|
||||
|
||||
interface Chunk {
|
||||
bb: [number, number, number, number];
|
||||
layers: Layer[];
|
||||
trees: Path2D;
|
||||
treeEdges: Path2D;
|
||||
trunks: Path2D;
|
||||
hasTrees: boolean;
|
||||
}
|
||||
|
||||
const CHUNK = 120;
|
||||
|
||||
export class Renderer {
|
||||
private areas: { kind: string; path: Path2D; bb: number[] }[] = [];
|
||||
private roadsByLayer: Road[][] = [];
|
||||
private chunks = new Map<string, Chunk>();
|
||||
private bGrid = new SpatialGrid<Building>(60);
|
||||
private roofCache = new Map<string, Path2D>();
|
||||
private floorCache = new Map<string, FloorPaths>();
|
||||
private streetLabels: { text: string; pos: Vec; ang: number; len: number; major: boolean }[] = [];
|
||||
private bounds: World['bounds'];
|
||||
|
||||
constructor(public world: World) {
|
||||
this.bounds = world.bounds;
|
||||
for (const a of world.areas) {
|
||||
const p = new Path2D();
|
||||
for (const r of a.rings) addRing(p, r);
|
||||
this.areas.push({ kind: a.kind, path: p, bb: bboxOf(a.rings[0]) });
|
||||
}
|
||||
const layers = new Map<number, Road[]>();
|
||||
for (const r of world.roads) {
|
||||
const l = r.tunnel ? -10 : r.bridge ? 10 + r.layer : Math.min(r.layer, 0);
|
||||
if (!layers.has(l)) layers.set(l, []);
|
||||
layers.get(l)!.push(r);
|
||||
}
|
||||
this.roadsByLayer = [...layers.entries()].sort((a, b) => a[0] - b[0]).map((e) => e[1]);
|
||||
for (const b of world.buildings) this.bGrid.insert(b, bboxOf(b.footprint));
|
||||
this.buildChunks();
|
||||
this.buildStreetLabels();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- подготовка
|
||||
|
||||
private buildChunks() {
|
||||
const groups = new Map<string, Prop[]>();
|
||||
for (const p of this.world.props) {
|
||||
const k = `${Math.floor(p.pos[0] / CHUNK)},${Math.floor(p.pos[1] / CHUNK)}`;
|
||||
if (!groups.has(k)) groups.set(k, []);
|
||||
groups.get(k)!.push(p);
|
||||
}
|
||||
for (const [k, props] of groups) {
|
||||
const [cx, cy] = k.split(',').map(Number);
|
||||
const trees = new Path2D(), treeEdges = new Path2D(), trunks = new Path2D();
|
||||
const rest: Prop[] = [];
|
||||
let hasTrees = false;
|
||||
for (const p of props) {
|
||||
if (p.kind === 'tree' || p.kind === 'bush') {
|
||||
const r = p.w / 2;
|
||||
trees.moveTo(p.pos[0] + r, p.pos[1]);
|
||||
trees.arc(p.pos[0], p.pos[1], r, 0, Math.PI * 2);
|
||||
treeEdges.moveTo(p.pos[0] + r, p.pos[1]);
|
||||
treeEdges.arc(p.pos[0], p.pos[1], r, 0, Math.PI * 2);
|
||||
if (p.kind === 'tree') {
|
||||
trunks.moveTo(p.pos[0] + 0.25, p.pos[1]);
|
||||
trunks.arc(p.pos[0], p.pos[1], 0.25, 0, Math.PI * 2);
|
||||
}
|
||||
hasTrees = true;
|
||||
} else rest.push(p);
|
||||
}
|
||||
this.chunks.set(k, {
|
||||
bb: [cx * CHUNK - 6, cy * CHUNK - 6, (cx + 1) * CHUNK + 6, (cy + 1) * CHUNK + 6],
|
||||
layers: this.propLayers(rest), trees, treeEdges, trunks, hasTrees,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private buildStreetLabels() {
|
||||
const byName = new Map<string, Road[]>();
|
||||
for (const r of this.world.roads) if (r.name && !['rail', 'tram', 'river', 'stream', 'canal', 'ditch', 'footway', 'path', 'cycleway', 'steps'].includes(r.kind)) {
|
||||
if (!byName.has(r.name)) byName.set(r.name, []);
|
||||
byName.get(r.name)!.push(r);
|
||||
}
|
||||
for (const [name, rs] of byName) {
|
||||
// подпись на самом длинном участке каждого пути
|
||||
for (const r of rs) {
|
||||
if (polylineLength(r.pts) < 40) continue;
|
||||
let best = 0, bi = 1;
|
||||
for (let i = 1; i < r.pts.length; i++) {
|
||||
const l = Math.hypot(r.pts[i][0] - r.pts[i - 1][0], r.pts[i][1] - r.pts[i - 1][1]);
|
||||
if (l > best) { best = l; bi = i; }
|
||||
}
|
||||
const a = r.pts[bi - 1], b = r.pts[bi];
|
||||
let ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
|
||||
if (ang > Math.PI / 2) ang -= Math.PI;
|
||||
if (ang < -Math.PI / 2) ang += Math.PI;
|
||||
this.streetLabels.push({ text: name, pos: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], ang, len: best, major: ['primary', 'secondary', 'trunk', 'tertiary'].includes(r.kind) });
|
||||
}
|
||||
}
|
||||
this.streetLabels.sort((a, b) => Number(b.major) - Number(a.major) || b.len - a.len);
|
||||
}
|
||||
|
||||
private shapesOf(p: Prop): Shape[] {
|
||||
return p.shapes ?? (p.model ? this.world.models[p.model] ?? [] : []);
|
||||
}
|
||||
|
||||
/** Фигуры предметов → слои Path2D: слой i каждого предмета рисуется после слоя i-1 всех предметов. */
|
||||
private propLayers(props: Prop[]): Layer[] {
|
||||
const map = new Map<string, Layer>();
|
||||
for (const p of props) {
|
||||
const shapes = this.shapesOf(p);
|
||||
const c = Math.cos(p.rot), s = Math.sin(p.rot);
|
||||
const T = (x: number, y: number): Vec => [p.pos[0] + x * c - y * s, p.pos[1] + x * s + y * c];
|
||||
shapes.forEach((sh, i) => {
|
||||
const style = sh.s ?? 'base';
|
||||
const fill = sh.t === 'line' ? null : style === 'car' ? p.color ?? '#999' : S.SHAPE_FILL[style] ?? '#eee';
|
||||
const key = `${i}|${fill}|${sh.t === 'line' ? style : ''}`;
|
||||
let L = map.get(key);
|
||||
if (!L) map.set(key, (L = { order: i * 2 + (sh.t === 'line' ? 1 : 0), fill, path: new Path2D() }));
|
||||
addShape(L.path, sh, T, p.rot);
|
||||
});
|
||||
}
|
||||
return [...map.values()].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
private roof(b: Building): Path2D {
|
||||
let p = this.roofCache.get(b.id);
|
||||
if (!p) {
|
||||
p = new Path2D();
|
||||
addRing(p, b.footprint);
|
||||
for (const h of b.holes) addRing(p, h);
|
||||
this.roofCache.set(b.id, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
private floorPaths(b: Building, f: Floor): FloorPaths {
|
||||
const key = `${b.id}|${f.level}`;
|
||||
let fp = this.floorCache.get(key);
|
||||
if (fp) return fp;
|
||||
const rooms = new Map<string, Path2D>();
|
||||
const labels: FloorPaths['labels'] = [];
|
||||
for (const r of f.rooms) {
|
||||
const col = S.ROOM_COLOR[r.kind] ?? S.ROOM_COLOR.default;
|
||||
let p = rooms.get(col);
|
||||
if (!p) rooms.set(col, (p = new Path2D()));
|
||||
for (const ring of r.rings) addRing(p, ring);
|
||||
const name = S.ROOM_NAME[r.kind];
|
||||
if (name && r.kind !== 'stairs' && r.kind !== 'lift') labels.push({ pos: r.labelPos, text: name, area: r.area });
|
||||
}
|
||||
const wallsExt = new Path2D(), wallsInt = new Path2D();
|
||||
for (const w of f.walls) {
|
||||
const p = w.exterior ? wallsExt : wallsInt;
|
||||
p.moveTo(w.a[0], w.a[1]);
|
||||
p.lineTo(w.b[0], w.b[1]);
|
||||
}
|
||||
const windows = new Path2D();
|
||||
for (const w of f.windows) { windows.moveTo(w.a[0], w.a[1]); windows.lineTo(w.b[0], w.b[1]); }
|
||||
const doors = new Path2D();
|
||||
for (const d of f.doors) {
|
||||
if (d.kind === 'opening') continue;
|
||||
const vx = d.b[0] - d.a[0], vy = d.b[1] - d.a[1];
|
||||
const L = Math.hypot(vx, vy);
|
||||
if (L < 0.3) continue;
|
||||
const mid: Vec = [(d.a[0] + d.b[0]) / 2, (d.a[1] + d.b[1]) / 2];
|
||||
let nx = -vy / L, ny = vx / L;
|
||||
if ((d.swingTo[0] - mid[0]) * nx + (d.swingTo[1] - mid[1]) * ny < 0) { nx = -nx; ny = -ny; }
|
||||
if (d.kind === 'garage') {
|
||||
doors.moveTo(d.a[0] + nx * 0.15, d.a[1] + ny * 0.15);
|
||||
doors.lineTo(d.b[0] + nx * 0.15, d.b[1] + ny * 0.15);
|
||||
continue;
|
||||
}
|
||||
// двустворчатая для широких проёмов
|
||||
const leaves: [Vec, Vec, number][] = L > 1.3 ? [[d.a, [vx / 2, vy / 2], L / 2], [d.b, [-vx / 2, -vy / 2], L / 2]] : [[d.a, [vx, vy], L]];
|
||||
for (const [h, v, r] of leaves) {
|
||||
const a0 = Math.atan2(v[1], v[0]);
|
||||
const a1 = Math.atan2(ny, nx);
|
||||
let da = a1 - a0;
|
||||
while (da > Math.PI) da -= 2 * Math.PI;
|
||||
while (da < -Math.PI) da += 2 * Math.PI;
|
||||
doors.moveTo(h[0], h[1]);
|
||||
doors.lineTo(h[0] + nx * r, h[1] + ny * r);
|
||||
doors.arc(h[0], h[1], r, a1, a0, da > 0);
|
||||
}
|
||||
}
|
||||
const stairs = new Path2D(), stairLines = new Path2D();
|
||||
for (const s of f.stairs) {
|
||||
for (const ring of s.rings) addRing(stairs, ring);
|
||||
if (s.lift) {
|
||||
const r = s.rings[0];
|
||||
stairLines.moveTo(r[0][0], r[0][1]); stairLines.lineTo(r[2][0], r[2][1]);
|
||||
stairLines.moveTo(r[1][0], r[1][1]); stairLines.lineTo(r[3][0], r[3][1]);
|
||||
continue;
|
||||
}
|
||||
const dx = s.to[0] - s.from[0], dy = s.to[1] - s.from[1];
|
||||
const L = Math.hypot(dx, dy);
|
||||
const ux = dx / L, uy = dy / L, px = -uy, py = ux;
|
||||
const hw = s.width / 2 - 0.05;
|
||||
for (let t = 0.28; t < L - 0.05; t += 0.28) {
|
||||
const cx = s.from[0] + ux * t, cy = s.from[1] + uy * t;
|
||||
stairLines.moveTo(cx - px * hw, cy - py * hw);
|
||||
stairLines.lineTo(cx + px * hw, cy + py * hw);
|
||||
}
|
||||
// стрелка «вверх»
|
||||
if (s.up) {
|
||||
const a: Vec = [s.from[0] + ux * 0.2, s.from[1] + uy * 0.2], b: Vec = [s.to[0] - ux * 0.3, s.to[1] - uy * 0.3];
|
||||
stairLines.moveTo(a[0], a[1]);
|
||||
stairLines.lineTo(b[0], b[1]);
|
||||
stairLines.lineTo(b[0] - ux * 0.35 + px * 0.25, b[1] - uy * 0.35 + py * 0.25);
|
||||
stairLines.moveTo(b[0], b[1]);
|
||||
stairLines.lineTo(b[0] - ux * 0.35 - px * 0.25, b[1] - uy * 0.35 - py * 0.25);
|
||||
}
|
||||
}
|
||||
fp = { rooms, wallsExt, wallsInt, windows, doors, stairs, stairLines, props: this.propLayers(f.props), labels };
|
||||
this.floorCache.set(key, fp);
|
||||
if (this.floorCache.size > 6000) this.floorCache.delete(this.floorCache.keys().next().value!);
|
||||
return fp;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- запросы
|
||||
|
||||
buildingAt(p: Vec): Building | null {
|
||||
for (const b of this.bGrid.query([p[0], p[1], p[0], p[1]])) if (pointInRings(p, [b.footprint, ...b.holes])) return b;
|
||||
return null;
|
||||
}
|
||||
|
||||
levelFor(b: Building, o: ViewOpts): number | null {
|
||||
const lv = o.overrides.has(b.id) ? o.overrides.get(b.id)! : o.floor;
|
||||
if (lv === null || lv === undefined) return null;
|
||||
return lv < b.levels ? lv : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- кадр
|
||||
|
||||
draw(ctx: CanvasRenderingContext2D, W: number, H: number, cam: Camera, o: ViewOpts, dpr = 1) {
|
||||
const z = cam.z;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.fillStyle = S.BG;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
const view: [number, number, number, number] = [cam.x - W / 2 / z, cam.y - H / 2 / z, cam.x + W / 2 / z, cam.y + H / 2 / z];
|
||||
const px = 1 / z; // один пиксель в метрах
|
||||
ctx.setTransform(z * dpr, 0, 0, z * dpr, (W / 2 - cam.x * z) * dpr, (H / 2 - cam.y * z) * dpr);
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
|
||||
// зоны
|
||||
for (const a of this.areas) {
|
||||
if (!bboxHit(a.bb, view)) continue;
|
||||
ctx.fillStyle = (S.AREA_COLOR as any)[a.kind] ?? '#ddd';
|
||||
ctx.fill(a.path, 'evenodd');
|
||||
}
|
||||
if (z > 1.5) {
|
||||
for (const a of this.areas) if (a.kind === 'parking' && bboxHit(a.bb, view)) { ctx.strokeStyle = '#c2c0bb'; ctx.lineWidth = Math.max(0.2, px); ctx.stroke(a.path); }
|
||||
}
|
||||
|
||||
// дороги по слоям: тоннели, земля, мосты
|
||||
for (const group of this.roadsByLayer) this.drawRoads(ctx, group, view, z, px);
|
||||
|
||||
// уличные объекты
|
||||
const chunks = [...this.chunks.values()].filter((c) => bboxHit(c.bb, view));
|
||||
if (z > 0.8) for (const c of chunks) this.drawLayers(ctx, c.layers, z, px);
|
||||
|
||||
// здания
|
||||
const visible = this.bGrid.query(view);
|
||||
visible.sort((a, b) => a.levels - b.levels);
|
||||
// тени от крыш
|
||||
if (z < 40) {
|
||||
ctx.save();
|
||||
ctx.translate(0.8, 1.2);
|
||||
ctx.fillStyle = 'rgba(40,30,20,0.16)';
|
||||
for (const b of visible) if (this.levelFor(b, o) === null) ctx.fill(this.roof(b), 'evenodd');
|
||||
ctx.restore();
|
||||
}
|
||||
for (const b of visible) {
|
||||
const lv = this.levelFor(b, o);
|
||||
const f = lv !== null ? b.floors[lv] : undefined;
|
||||
if (f && z >= 0.9) this.drawFloor(ctx, b, f, z, px, o);
|
||||
else this.drawRoof(ctx, b, z, px, lv !== null);
|
||||
if (o.selected === b.id) {
|
||||
ctx.strokeStyle = '#e8590c';
|
||||
ctx.lineWidth = Math.max(3 * px, 0.25);
|
||||
ctx.stroke(this.roof(b));
|
||||
}
|
||||
}
|
||||
|
||||
// кроны деревьев поверх
|
||||
if (z > 0.35) {
|
||||
for (const c of chunks) {
|
||||
if (!c.hasTrees) continue;
|
||||
ctx.fillStyle = S.TREE;
|
||||
ctx.fill(c.trees);
|
||||
if (z > 1.5) {
|
||||
ctx.strokeStyle = S.TREE_EDGE;
|
||||
ctx.lineWidth = Math.max(px, 0.06);
|
||||
ctx.stroke(c.treeEdges);
|
||||
ctx.fillStyle = 'rgba(90,64,40,0.7)';
|
||||
ctx.fill(c.trunks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// затемнение за пределами области
|
||||
const B = this.bounds;
|
||||
ctx.fillStyle = S.OUTSIDE;
|
||||
const big = 1e6;
|
||||
const outside = new Path2D();
|
||||
outside.rect(-big, -big, 2 * big, 2 * big);
|
||||
outside.rect(B.minX, B.minY, B.maxX - B.minX, B.maxY - B.minY);
|
||||
ctx.fill(outside, 'evenodd');
|
||||
|
||||
// подписи — в экранных координатах
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const toS = (p: Vec): Vec => [(p[0] - cam.x) * z + W / 2, (p[1] - cam.y) * z + H / 2];
|
||||
const boxes: number[][] = [];
|
||||
const free = (bx: number[]) => {
|
||||
if (boxes.some((b) => bboxHit(b, bx))) return false;
|
||||
boxes.push(bx);
|
||||
return true;
|
||||
};
|
||||
if (o.labels && z >= 9) {
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = `${z > 30 ? 13 : 11}px system-ui, sans-serif`;
|
||||
for (const b of visible) {
|
||||
const lv = this.levelFor(b, o);
|
||||
if (lv === null || !b.floors[lv]) continue;
|
||||
const fp = this.floorPaths(b, b.floors[lv]);
|
||||
for (const l of fp.labels) {
|
||||
const sp = toS(l.pos);
|
||||
if (sp[0] < -50 || sp[1] < -20 || sp[0] > W + 50 || sp[1] > H + 20) continue;
|
||||
const tw = ctx.measureText(l.text).width;
|
||||
if (Math.sqrt(l.area) * z < tw * 0.9) continue;
|
||||
const yOff = z > 30 ? -12 : 0;
|
||||
if (!free([sp[0] - tw / 2, sp[1] + yOff - 7, sp[0] + tw / 2, sp[1] + yOff + 7])) continue;
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.75)';
|
||||
ctx.fillText(l.text, sp[0] + 1, sp[1] + yOff + 1);
|
||||
ctx.fillStyle = '#5a554d';
|
||||
ctx.fillText(l.text, sp[0], sp[1] + yOff);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (o.labels && z > 0.6 && z < 14) {
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (const l of this.streetLabels) {
|
||||
const sp = toS(l.pos);
|
||||
if (sp[0] < -100 || sp[1] < -50 || sp[0] > W + 100 || sp[1] > H + 50) continue;
|
||||
ctx.font = `${l.major ? 600 : 500} ${l.major ? 13 : 12}px system-ui, sans-serif`;
|
||||
const tw = ctx.measureText(l.text).width;
|
||||
if (tw > l.len * z * 0.95) continue;
|
||||
const hw = (Math.abs(Math.cos(l.ang)) * tw + Math.abs(Math.sin(l.ang)) * 14) / 2;
|
||||
const hh = (Math.abs(Math.sin(l.ang)) * tw + Math.abs(Math.cos(l.ang)) * 14) / 2;
|
||||
if (!free([sp[0] - hw, sp[1] - hh, sp[0] + hw, sp[1] + hh])) continue;
|
||||
ctx.save();
|
||||
ctx.translate(sp[0], sp[1]);
|
||||
ctx.rotate(l.ang);
|
||||
ctx.lineWidth = 3.5;
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.strokeText(l.text, 0, 0);
|
||||
ctx.fillStyle = '#3e3a35';
|
||||
ctx.fillText(l.text, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawRoads(ctx: CanvasRenderingContext2D, roads: Road[], view: number[], z: number, px: number) {
|
||||
const vis = roads.filter((r) => {
|
||||
const bb = bboxOf(r.pts);
|
||||
const m = r.width / 2 + r.sidewalk + 2;
|
||||
return bboxHit([bb[0] - m, bb[1] - m, bb[2] + m, bb[3] + m], view);
|
||||
});
|
||||
const line = (r: Road, p: Path2D = new Path2D()) => {
|
||||
p.moveTo(r.pts[0][0], r.pts[0][1]);
|
||||
for (let i = 1; i < r.pts.length; i++) p.lineTo(r.pts[i][0], r.pts[i][1]);
|
||||
return p;
|
||||
};
|
||||
const tunnel = vis.length > 0 && vis[0].tunnel;
|
||||
ctx.save();
|
||||
if (tunnel) {
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.setLineDash([3, 2]);
|
||||
}
|
||||
const water = vis.filter((r) => ['river', 'stream', 'canal', 'ditch'].includes(r.kind));
|
||||
for (const r of water) {
|
||||
ctx.strokeStyle = S.ROAD.water;
|
||||
ctx.lineWidth = Math.max(r.width, 1.5 * px);
|
||||
ctx.stroke(line(r));
|
||||
}
|
||||
const isCar = (r: Road) => !['footway', 'path', 'cycleway', 'steps', 'track', 'pedestrian', 'rail', 'tram', 'river', 'stream', 'canal', 'ditch'].includes(r.kind);
|
||||
const car = vis.filter(isCar);
|
||||
// мосты: тёмная обводка
|
||||
for (const r of vis) if (r.bridge && !['rail', 'tram'].includes(r.kind)) {
|
||||
ctx.strokeStyle = S.ROAD.bridgeCasing;
|
||||
ctx.lineWidth = Math.max(r.width + 2 * r.sidewalk + 1.2, 3 * px);
|
||||
ctx.lineCap = 'butt';
|
||||
ctx.stroke(line(r));
|
||||
ctx.lineCap = 'round';
|
||||
}
|
||||
// тротуары и бордюр
|
||||
for (const r of car) if (r.sidewalk > 0) {
|
||||
ctx.strokeStyle = S.ROAD.kerb;
|
||||
ctx.lineWidth = Math.max(r.width + 2 * r.sidewalk + 0.3, 2 * px);
|
||||
ctx.stroke(line(r));
|
||||
ctx.strokeStyle = S.ROAD.sidewalk;
|
||||
ctx.lineWidth = Math.max(r.width + 2 * r.sidewalk, 2 * px);
|
||||
ctx.stroke(line(r));
|
||||
}
|
||||
// пешеходные: площади/дорожки
|
||||
for (const r of vis) {
|
||||
if (!['pedestrian', 'footway', 'path', 'cycleway', 'steps', 'track'].includes(r.kind)) continue;
|
||||
ctx.strokeStyle = r.kind === 'track' ? S.ROAD.track : r.kind === 'path' ? S.ROAD.path : r.kind === 'pedestrian' ? S.ROAD.sidewalk : S.ROAD.footway;
|
||||
ctx.lineWidth = Math.max(r.width, 1.2 * px);
|
||||
ctx.stroke(line(r));
|
||||
if (r.kind === 'steps' && z > 4) {
|
||||
ctx.save();
|
||||
ctx.setLineDash([0.12, 0.3]);
|
||||
ctx.lineCap = 'butt';
|
||||
ctx.strokeStyle = '#a89c88';
|
||||
ctx.lineWidth = r.width;
|
||||
ctx.stroke(line(r));
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
// проезжая часть
|
||||
for (const r of car) {
|
||||
const major = ['motorway', 'trunk', 'primary', 'secondary', 'tertiary'].includes(r.kind);
|
||||
ctx.strokeStyle = r.kind === 'service' ? S.ROAD.service : major ? S.ROAD.major : S.ROAD.minor;
|
||||
ctx.lineWidth = Math.max(r.width, (major ? 2.5 : 1.6) * px);
|
||||
ctx.stroke(line(r));
|
||||
}
|
||||
// разметка
|
||||
if (z > 1.5) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = S.ROAD.centre;
|
||||
ctx.lineWidth = 0.15;
|
||||
ctx.lineCap = 'butt';
|
||||
ctx.setLineDash([3, 4]);
|
||||
for (const r of car) if (r.width >= 6 && !r.oneway && r.kind !== 'service') ctx.stroke(line(r));
|
||||
ctx.restore();
|
||||
}
|
||||
// рельсы
|
||||
for (const r of vis) if (r.kind === 'rail' || r.kind === 'tram') {
|
||||
if (z > 2) {
|
||||
ctx.save();
|
||||
ctx.lineCap = 'butt';
|
||||
ctx.strokeStyle = S.ROAD.sleeper;
|
||||
ctx.lineWidth = 2.6;
|
||||
ctx.setLineDash([0.25, 0.4]);
|
||||
ctx.stroke(line(r));
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.strokeStyle = S.ROAD.rail;
|
||||
ctx.lineWidth = Math.max(z > 2 ? 1.5 : 1.2, 1.5 * px);
|
||||
ctx.stroke(line(r));
|
||||
if (z > 2) {
|
||||
ctx.strokeStyle = S.ROAD.sleeper;
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.stroke(line(r));
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private drawLayers(ctx: CanvasRenderingContext2D, layers: Layer[], z: number, px: number) {
|
||||
const outline = z > 6;
|
||||
ctx.lineWidth = Math.max(0.025, px * 0.8);
|
||||
for (const L of layers) {
|
||||
if (L.fill) {
|
||||
ctx.fillStyle = L.fill;
|
||||
ctx.fill(L.path);
|
||||
if (outline) {
|
||||
ctx.strokeStyle = S.SHAPE_STROKE;
|
||||
ctx.stroke(L.path);
|
||||
}
|
||||
} else if (z > 3) {
|
||||
ctx.strokeStyle = S.SHAPE_STROKE;
|
||||
ctx.stroke(L.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawRoof(ctx: CanvasRenderingContext2D, b: Building, z: number, px: number, lower: boolean) {
|
||||
const p = this.roof(b);
|
||||
ctx.fillStyle = S.ROOF[b.kind] ?? '#c8bba9';
|
||||
ctx.fill(p, 'evenodd');
|
||||
if (lower) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.18)';
|
||||
ctx.fill(p, 'evenodd');
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(70,58,46,0.75)';
|
||||
ctx.lineWidth = Math.max(px, 0.15);
|
||||
ctx.stroke(p);
|
||||
// конёк для небольших домов при приближении
|
||||
if (z > 3 && (b.kind === 'house' || b.kind === 'garage' || b.kind === 'shed' || b.kind === 'farm') && b.footprint.length <= 6) {
|
||||
const r = b.footprint;
|
||||
const c = Math.cos(b.frame.angle), s = Math.sin(b.frame.angle);
|
||||
const loc = r.map((q) => [q[0] * c + q[1] * s, -q[0] * s + q[1] * c]);
|
||||
const xs = loc.map((q) => q[0]), ys = loc.map((q) => q[1]);
|
||||
const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
|
||||
const ym = (y0 + y1) / 2, inset = (y1 - y0) / 2;
|
||||
const a: Vec = [x0 + inset * 0.6, ym], bb: Vec = [x1 - inset * 0.6, ym];
|
||||
const W = (q: Vec): Vec => [q[0] * c - q[1] * s, q[0] * s + q[1] * c];
|
||||
const pa = W(a), pb = W(bb);
|
||||
ctx.save();
|
||||
ctx.clip(p, 'evenodd');
|
||||
ctx.strokeStyle = 'rgba(90,70,55,0.45)';
|
||||
ctx.lineWidth = Math.max(px, 0.08);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pa[0], pa[1]);
|
||||
ctx.lineTo(pb[0], pb[1]);
|
||||
for (const corner of r) {
|
||||
const cl: Vec = [corner[0] * c + corner[1] * s, -corner[0] * s + corner[1] * c];
|
||||
const tgt = W(cl[0] < (x0 + x1) / 2 ? a : bb);
|
||||
ctx.moveTo(corner[0], corner[1]);
|
||||
ctx.lineTo(tgt[0], tgt[1]);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
private drawFloor(ctx: CanvasRenderingContext2D, b: Building, f: Floor, z: number, px: number, o: ViewOpts) {
|
||||
const roof = this.roof(b);
|
||||
if (z < 1.6) {
|
||||
ctx.fillStyle = S.ROOM_COLOR.default;
|
||||
ctx.fill(roof, 'evenodd');
|
||||
ctx.strokeStyle = S.WALL_EXT;
|
||||
ctx.lineWidth = Math.max(px * 1.2, 0.3);
|
||||
ctx.stroke(roof);
|
||||
return;
|
||||
}
|
||||
const fp = this.floorPaths(b, f);
|
||||
ctx.save();
|
||||
ctx.clip(roof, 'evenodd');
|
||||
ctx.fillStyle = S.ROOM_COLOR.default;
|
||||
ctx.fill(roof, 'evenodd');
|
||||
for (const [col, p] of fp.rooms) {
|
||||
ctx.fillStyle = col;
|
||||
ctx.fill(p, 'evenodd');
|
||||
}
|
||||
ctx.fillStyle = '#dcd5ca';
|
||||
ctx.fill(fp.stairs);
|
||||
ctx.strokeStyle = '#8f877b';
|
||||
ctx.lineWidth = Math.max(px * 0.8, 0.03);
|
||||
ctx.lineCap = 'butt';
|
||||
ctx.stroke(fp.stairLines);
|
||||
if (o.furniture && z >= 2.5) this.drawLayers(ctx, fp.props, z, px);
|
||||
ctx.lineCap = 'square';
|
||||
ctx.strokeStyle = S.WALL_INT;
|
||||
ctx.lineWidth = Math.max(0.12, px * 1.0);
|
||||
ctx.stroke(fp.wallsInt);
|
||||
ctx.restore();
|
||||
// внешние стены (не клипуем — толщина наружу)
|
||||
ctx.lineCap = 'square';
|
||||
ctx.strokeStyle = S.WALL_EXT;
|
||||
ctx.lineWidth = Math.max(0.3, px * 1.6);
|
||||
ctx.stroke(fp.wallsExt);
|
||||
if (z > 2) {
|
||||
ctx.lineCap = 'butt';
|
||||
ctx.strokeStyle = '#eef5f9';
|
||||
ctx.lineWidth = 0.22;
|
||||
ctx.stroke(fp.windows);
|
||||
ctx.strokeStyle = S.WINDOW;
|
||||
ctx.lineWidth = Math.max(0.05, px * 0.8);
|
||||
ctx.stroke(fp.windows);
|
||||
}
|
||||
if (z > 5) {
|
||||
ctx.lineCap = 'butt';
|
||||
ctx.strokeStyle = S.DOOR;
|
||||
ctx.lineWidth = Math.max(0.03, px * 0.8);
|
||||
ctx.stroke(fp.doors);
|
||||
}
|
||||
ctx.lineCap = 'round';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- геометрия Path2D
|
||||
|
||||
function addRing(p: Path2D, r: Vec[]) {
|
||||
if (r.length < 2) return;
|
||||
p.moveTo(r[0][0], r[0][1]);
|
||||
for (let i = 1; i < r.length; i++) p.lineTo(r[i][0], r[i][1]);
|
||||
p.closePath();
|
||||
}
|
||||
|
||||
function addShape(p: Path2D, sh: Shape, T: (x: number, y: number) => Vec, rot: number) {
|
||||
if (sh.t === 'circle') {
|
||||
const c = T(sh.x, sh.y);
|
||||
p.moveTo(c[0] + sh.r, c[1]);
|
||||
p.arc(c[0], c[1], sh.r, 0, Math.PI * 2);
|
||||
return;
|
||||
}
|
||||
if (sh.t === 'line') {
|
||||
const a = T(sh.x1, sh.y1), b = T(sh.x2, sh.y2);
|
||||
p.moveTo(a[0], a[1]);
|
||||
p.lineTo(b[0], b[1]);
|
||||
return;
|
||||
}
|
||||
const hw = sh.w / 2, hh = sh.h / 2;
|
||||
const r = Math.min(sh.r ?? 0, hw, hh);
|
||||
const corners: Vec[] = [T(sh.x - hw, sh.y - hh), T(sh.x + hw, sh.y - hh), T(sh.x + hw, sh.y + hh), T(sh.x - hw, sh.y + hh)];
|
||||
if (r <= 0.01) {
|
||||
p.moveTo(corners[0][0], corners[0][1]);
|
||||
for (let i = 1; i < 4; i++) p.lineTo(corners[i][0], corners[i][1]);
|
||||
p.closePath();
|
||||
return;
|
||||
}
|
||||
const m = (a: Vec, b: Vec): Vec => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
const start = m(corners[3], corners[0]);
|
||||
p.moveTo(start[0], start[1]);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const c = corners[i], n = corners[(i + 1) % 4];
|
||||
p.arcTo(c[0], c[1], n[0], n[1], r);
|
||||
}
|
||||
p.closePath();
|
||||
void rot;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { AreaKind, BuildingKind, RoomKind, ShapeStyle } from '../types';
|
||||
|
||||
export const BG = '#ebe7de';
|
||||
export const OUTSIDE = 'rgba(40,38,34,0.38)';
|
||||
|
||||
export const AREA_COLOR: Record<AreaKind, string> = {
|
||||
residential: '#e5e0d5', commercial: '#ead9d3', retail: '#eed6d2', industrial: '#e0d9e2', farmland: '#ecedd2',
|
||||
farmyard: '#e7d9c2', grass: '#cfe3b0', park: '#c6e2a6', forest: '#a9cc8f', scrub: '#c4d7a1', meadow: '#d7e9b8',
|
||||
orchard: '#c9e0a6', vineyard: '#d4e3ae', water: '#a8cde6', wetland: '#c3dcd6', beach: '#f0e3bd', sand: '#ebdfbf',
|
||||
parking: '#d7d6d3', pitch: '#b3d79e', playground: '#d6ecc9', cemetery: '#c3d7b4', construction: '#ddd5c4',
|
||||
railway: '#dad5cc', school: '#efe5cc', hospital: '#efdcdc', pedestrian: '#e0dbd2', garden: '#d3e8b7',
|
||||
};
|
||||
|
||||
export const ROAD = {
|
||||
major: '#8e9196',
|
||||
minor: '#a3a6ab',
|
||||
service: '#b4b6b9',
|
||||
sidewalk: '#d4d0c8',
|
||||
kerb: '#b9b4ab',
|
||||
centre: '#f4f1e8',
|
||||
footway: '#e3d6bd',
|
||||
path: '#d4c29f',
|
||||
track: '#c7b28c',
|
||||
rail: '#6f6a64',
|
||||
sleeper: '#a59b8d',
|
||||
water: '#8fc0e0',
|
||||
bridgeCasing: '#5e5a54',
|
||||
};
|
||||
|
||||
export const ROOF: Record<BuildingKind, string> = {
|
||||
house: '#d2b9a3', apartments: '#c4b3a6', mixed: '#c8b2ad', retail: '#c9b5c3', restaurant: '#cdb3b0',
|
||||
office: '#b9bfc9', industrial: '#bdbcc2', warehouse: '#b6b8bd', school: '#d6c4a0', hospital: '#d6bcbc',
|
||||
hotel: '#c9b9b0', church: '#c4a98a', civic: '#cfc19f', garage: '#bab6b0', shed: '#c7bba8', farm: '#c9b394',
|
||||
};
|
||||
|
||||
export const ROOM_COLOR: Partial<Record<RoomKind, string>> & { default: string } = {
|
||||
default: '#f6f3ed',
|
||||
living: '#f4ead8', bedroom: '#ece7f2', kitchen: '#f3eed4', bath: '#dbeaf0', wc: '#dbeaf0', hall: '#efebe5',
|
||||
corridor: '#ece8e1', lobby: '#ece8e1', landing: '#e9e5de', stairs: '#e2ddd4', lift: '#d6d2cb', storage: '#ebe5d9', utility: '#e5e8e4',
|
||||
sales: '#f6ecd9', backroom: '#ebe5d9', office: '#e8edf1', openoffice: '#e8edf1', meeting: '#e3e9f0',
|
||||
dining: '#f5e6d8', prokitchen: '#e6ebec', workshop: '#e6e5e1', hall_ind: '#e4e4e2', classroom: '#f2ecd9',
|
||||
staff: '#ecebe2', ward: '#e6f0ee', guestroom: '#f0e8f0', nave: '#f1e9da', garage: '#e1e1df', void: '#d9d6d0',
|
||||
};
|
||||
|
||||
export const ROOM_NAME: Record<RoomKind, string> = {
|
||||
hall: 'прихожая', landing: 'площадка', living: 'гостиная', kitchen: 'кухня', bedroom: 'спальня', bath: 'ванная', wc: 'туалет',
|
||||
storage: 'кладовая', corridor: 'коридор', stairs: 'лестница', lift: 'лифт', lobby: 'вестибюль', sales: 'торговый зал',
|
||||
backroom: 'подсобка', office: 'кабинет', openoffice: 'опенспейс', meeting: 'переговорная', dining: 'зал',
|
||||
prokitchen: 'кухня', workshop: 'мастерская', hall_ind: 'цех', classroom: 'класс', staff: 'учительская', ward: 'палата',
|
||||
guestroom: 'номер', nave: 'зал', garage: 'гараж', utility: 'техпомещение', void: '',
|
||||
};
|
||||
|
||||
export const KIND_NAME: Record<BuildingKind, string> = {
|
||||
house: 'Жилой дом', apartments: 'Многоквартирный дом', mixed: 'Жилой дом с магазином', retail: 'Магазин',
|
||||
restaurant: 'Кафе / ресторан', office: 'Офис', industrial: 'Производство', warehouse: 'Склад / ангар', school: 'Школа',
|
||||
hospital: 'Больница', hotel: 'Гостиница', church: 'Храм', civic: 'Общественное здание', garage: 'Гараж',
|
||||
shed: 'Сарай / хозпостройка', farm: 'Хозяйственная постройка',
|
||||
};
|
||||
|
||||
export const SHAPE_FILL: Record<ShapeStyle, string | null> = {
|
||||
base: '#fbfaf6', soft: '#ffffff', dark: '#5f5b55', green: '#86b26e', glass: '#bcd9e8', metal: '#bfc4c8',
|
||||
wood: '#c9a57f', water: '#cfe6f2', line: null, car: '#999', fabric: '#b8a48f', accent: '#c88e64',
|
||||
};
|
||||
export const SHAPE_STROKE = '#5c564e';
|
||||
|
||||
export const WALL_EXT = '#33312e';
|
||||
export const WALL_INT = '#4d4a45';
|
||||
export const WINDOW = '#5d93b8';
|
||||
export const DOOR = '#7b766f';
|
||||
export const TREE = 'rgba(96,148,72,0.78)';
|
||||
export const TREE_EDGE = 'rgba(64,104,48,0.9)';
|
||||
@@ -0,0 +1,288 @@
|
||||
// Просмотрщик мира: камера (перетаскивание, колесо, щипок), этажи, выбор здания.
|
||||
|
||||
import type { Building, Vec, World } from '../types';
|
||||
import { Renderer, type Camera, type ViewOpts } from './renderer';
|
||||
import { KIND_NAME, ROOM_NAME } from './style';
|
||||
import { centroid } from '../util/geom';
|
||||
|
||||
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, html?: string) => {
|
||||
const e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (html !== undefined) e.innerHTML = html;
|
||||
return e;
|
||||
};
|
||||
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]!));
|
||||
|
||||
export class Viewer {
|
||||
readonly root = el('div', 'viewer');
|
||||
private canvas = el('canvas', 'world-canvas');
|
||||
private ctx = this.canvas.getContext('2d')!;
|
||||
private renderer: Renderer;
|
||||
cam: Camera;
|
||||
opts: ViewOpts = { floor: null, overrides: new Map(), selected: null, furniture: true, labels: true };
|
||||
private raf = 0;
|
||||
private floorBar = el('div', 'floor-bar card');
|
||||
private info = el('div', 'info card');
|
||||
private scale = el('div', 'scale');
|
||||
private maxLevel: number;
|
||||
private pointers = new Map<number, { x: number; y: number }>();
|
||||
private gesture: { x: number; y: number; cam: Camera; d?: number; moved: boolean } | null = null;
|
||||
|
||||
constructor(parent: HTMLElement, public world: World, private actions: { onNew: () => void; onExport: () => void }) {
|
||||
this.renderer = new Renderer(world);
|
||||
const B = world.bounds;
|
||||
this.cam = { x: (B.minX + B.maxX) / 2, y: (B.minY + B.maxY) / 2, z: 1 };
|
||||
this.maxLevel = Math.min(40, Math.max(1, ...world.buildings.map((b) => b.levels)));
|
||||
this.root.append(this.canvas, this.buildTop(), this.floorBar, this.info, this.scale, el('div', 'attribution', '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors'));
|
||||
parent.appendChild(this.root);
|
||||
this.info.hidden = true;
|
||||
this.buildFloorBar();
|
||||
new ResizeObserver(() => this.resize()).observe(this.root);
|
||||
this.bindInput();
|
||||
requestAnimationFrame(() => {
|
||||
this.resize();
|
||||
this.fitBounds();
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.root.remove();
|
||||
window.removeEventListener('keydown', this.onKey);
|
||||
}
|
||||
|
||||
private buildTop(): HTMLElement {
|
||||
const top = el('div', 'viewer-top card');
|
||||
const w = this.world;
|
||||
let floors = 0, rooms = 0, furn = 0, beds = 0, homes = 0;
|
||||
for (const b of w.buildings) {
|
||||
floors += b.floors.length;
|
||||
beds += b.beds;
|
||||
homes += b.units.filter((u) => u.kind === 'home').length;
|
||||
for (const f of b.floors) { rooms += f.rooms.length; furn += f.props.length; }
|
||||
}
|
||||
top.innerHTML = `
|
||||
<div class="title">${esc(w.name || 'Без названия')}</div>
|
||||
<div class="stats">${w.buildings.length.toLocaleString('ru')} зданий · ${floors.toLocaleString('ru')} этажей · ${rooms.toLocaleString('ru')} комнат · ${furn.toLocaleString('ru')} предметов · ${homes.toLocaleString('ru')} квартир/домов · ~${beds.toLocaleString('ru')} жителей</div>
|
||||
<div class="row">
|
||||
<button class="btn" data-a="new">Новая карта</button>
|
||||
<button class="btn" data-a="export" title="Скачать мир целиком (JSON) — формат для будущей игры">Экспорт мира</button>
|
||||
<label class="chip"><input type="checkbox" data-t="furniture" checked> Мебель</label>
|
||||
<label class="chip"><input type="checkbox" data-t="labels" checked> Подписи</label>
|
||||
</div>`;
|
||||
top.querySelector('[data-a=new]')!.addEventListener('click', () => this.actions.onNew());
|
||||
top.querySelector('[data-a=export]')!.addEventListener('click', () => this.actions.onExport());
|
||||
top.querySelectorAll<HTMLInputElement>('input[data-t]').forEach((inp) =>
|
||||
inp.addEventListener('change', () => {
|
||||
(this.opts as any)[inp.dataset.t!] = inp.checked;
|
||||
this.request();
|
||||
}));
|
||||
return top;
|
||||
}
|
||||
|
||||
private buildFloorBar() {
|
||||
const bar = this.floorBar;
|
||||
bar.innerHTML = '<div class="floor-title">Этаж</div>';
|
||||
const list = el('div', 'floor-list');
|
||||
const mk = (label: string, value: number | null, title: string) => {
|
||||
const b = el('button', 'floor-btn', label);
|
||||
b.title = title;
|
||||
b.dataset.v = value === null ? 'roof' : String(value);
|
||||
b.addEventListener('click', () => this.setFloor(value));
|
||||
return b;
|
||||
};
|
||||
list.appendChild(mk('⌂', null, 'Крыши'));
|
||||
for (let l = this.maxLevel - 1; l >= 0; l--) list.appendChild(mk(String(l + 1), l, `${l + 1}-й этаж`));
|
||||
bar.appendChild(list);
|
||||
bar.appendChild(el('div', 'floor-hint', 'PgUp / PgDn'));
|
||||
this.syncFloorBar();
|
||||
}
|
||||
|
||||
private syncFloorBar() {
|
||||
const v = this.opts.floor === null ? 'roof' : String(this.opts.floor);
|
||||
this.floorBar.querySelectorAll<HTMLButtonElement>('.floor-btn').forEach((b) => b.classList.toggle('on', b.dataset.v === v));
|
||||
}
|
||||
|
||||
setFloor(v: number | null) {
|
||||
this.opts.floor = v;
|
||||
this.opts.overrides.clear();
|
||||
this.syncFloorBar();
|
||||
this.updateInfo();
|
||||
this.request();
|
||||
}
|
||||
|
||||
private resize() {
|
||||
const r = this.root.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
this.canvas.width = Math.round(r.width * dpr);
|
||||
this.canvas.height = Math.round(r.height * dpr);
|
||||
this.canvas.style.width = r.width + 'px';
|
||||
this.canvas.style.height = r.height + 'px';
|
||||
this.request();
|
||||
}
|
||||
|
||||
fitBounds() {
|
||||
const B = this.world.bounds;
|
||||
const r = this.root.getBoundingClientRect();
|
||||
this.cam.x = (B.minX + B.maxX) / 2;
|
||||
this.cam.y = (B.minY + B.maxY) / 2;
|
||||
this.cam.z = Math.min(r.width / (B.maxX - B.minX), r.height / (B.maxY - B.minY)) * 0.92;
|
||||
this.request();
|
||||
}
|
||||
|
||||
/** Перелёт к точке (для отладки и будущих событий). */
|
||||
lookAt(p: Vec, z: number) {
|
||||
this.cam = { x: p[0], y: p[1], z };
|
||||
this.request();
|
||||
}
|
||||
|
||||
request() {
|
||||
if (this.raf) return;
|
||||
this.raf = requestAnimationFrame(() => {
|
||||
this.raf = 0;
|
||||
const r = this.root.getBoundingClientRect();
|
||||
this.renderer.draw(this.ctx, r.width, r.height, this.cam, this.opts, window.devicePixelRatio || 1);
|
||||
this.updateScale();
|
||||
});
|
||||
}
|
||||
|
||||
private updateScale() {
|
||||
const z = this.cam.z;
|
||||
const target = 120 / z;
|
||||
const nice = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000].find((n) => n >= target * 0.6) ?? 5000;
|
||||
this.scale.style.width = nice * z + 'px';
|
||||
this.scale.textContent = nice >= 1000 ? `${nice / 1000} км` : `${nice} м`;
|
||||
}
|
||||
|
||||
private screenToWorld(x: number, y: number): Vec {
|
||||
const r = this.root.getBoundingClientRect();
|
||||
return [(x - r.width / 2) / this.cam.z + this.cam.x, (y - r.height / 2) / this.cam.z + this.cam.y];
|
||||
}
|
||||
|
||||
private zoomAt(x: number, y: number, factor: number) {
|
||||
const before = this.screenToWorld(x, y);
|
||||
this.cam.z = Math.max(0.05, Math.min(120, this.cam.z * factor));
|
||||
const after = this.screenToWorld(x, y);
|
||||
this.cam.x += before[0] - after[0];
|
||||
this.cam.y += before[1] - after[1];
|
||||
this.request();
|
||||
}
|
||||
|
||||
private onKey = (e: KeyboardEvent) => {
|
||||
if ((e.target as HTMLElement)?.tagName === 'INPUT') return;
|
||||
const f = this.opts.floor;
|
||||
if (e.key === 'PageUp' || e.key === ']') this.setFloor(f === null ? 0 : Math.min(this.maxLevel - 1, f + 1));
|
||||
else if (e.key === 'PageDown' || e.key === '[') this.setFloor(f === null || f === 0 ? null : f - 1);
|
||||
else if (e.key === 'Escape') this.select(null);
|
||||
else if (e.key === '+' || e.key === '=') this.zoomAt(this.canvas.clientWidth / 2, this.canvas.clientHeight / 2, 1.3);
|
||||
else if (e.key === '-') this.zoomAt(this.canvas.clientWidth / 2, this.canvas.clientHeight / 2, 1 / 1.3);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
private bindInput() {
|
||||
const c = this.canvas;
|
||||
c.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const k = e.ctrlKey ? 0.01 : 0.0015;
|
||||
this.zoomAt(e.offsetX, e.offsetY, Math.exp(-e.deltaY * k));
|
||||
}, { passive: false });
|
||||
c.addEventListener('pointerdown', (e) => {
|
||||
c.setPointerCapture(e.pointerId);
|
||||
this.pointers.set(e.pointerId, { x: e.offsetX, y: e.offsetY });
|
||||
const ps = [...this.pointers.values()];
|
||||
const mx = ps.reduce((s, p) => s + p.x, 0) / ps.length, my = ps.reduce((s, p) => s + p.y, 0) / ps.length;
|
||||
this.gesture = { x: mx, y: my, cam: { ...this.cam }, d: ps.length === 2 ? Math.hypot(ps[0].x - ps[1].x, ps[0].y - ps[1].y) : undefined, moved: false };
|
||||
});
|
||||
c.addEventListener('pointermove', (e) => {
|
||||
if (!this.pointers.has(e.pointerId) || !this.gesture) return;
|
||||
this.pointers.set(e.pointerId, { x: e.offsetX, y: e.offsetY });
|
||||
const ps = [...this.pointers.values()];
|
||||
const mx = ps.reduce((s, p) => s + p.x, 0) / ps.length, my = ps.reduce((s, p) => s + p.y, 0) / ps.length;
|
||||
const g = this.gesture;
|
||||
if (Math.hypot(mx - g.x, my - g.y) > 4) g.moved = true;
|
||||
if (ps.length === 2 && g.d) {
|
||||
const d = Math.hypot(ps[0].x - ps[1].x, ps[0].y - ps[1].y);
|
||||
this.cam.z = Math.max(0.05, Math.min(120, g.cam.z * (d / g.d)));
|
||||
g.moved = true;
|
||||
}
|
||||
const r = this.root.getBoundingClientRect();
|
||||
const anchor: Vec = [(g.x - r.width / 2) / g.cam.z + g.cam.x, (g.y - r.height / 2) / g.cam.z + g.cam.y];
|
||||
this.cam.x = anchor[0] - (mx - r.width / 2) / this.cam.z;
|
||||
this.cam.y = anchor[1] - (my - r.height / 2) / this.cam.z;
|
||||
this.request();
|
||||
});
|
||||
const end = (e: PointerEvent) => {
|
||||
const g = this.gesture;
|
||||
this.pointers.delete(e.pointerId);
|
||||
if (g && !g.moved && this.pointers.size === 0) {
|
||||
const b = this.renderer.buildingAt(this.screenToWorld(e.offsetX, e.offsetY));
|
||||
this.select(b?.id ?? null);
|
||||
}
|
||||
if (this.pointers.size) {
|
||||
const ps = [...this.pointers.values()];
|
||||
this.gesture = { x: ps[0].x, y: ps[0].y, cam: { ...this.cam }, moved: true };
|
||||
} else this.gesture = null;
|
||||
};
|
||||
c.addEventListener('pointerup', end);
|
||||
c.addEventListener('pointercancel', end);
|
||||
c.addEventListener('dblclick', (e) => this.zoomAt(e.offsetX, e.offsetY, 2));
|
||||
window.addEventListener('keydown', this.onKey);
|
||||
}
|
||||
|
||||
select(id: string | null) {
|
||||
this.opts.selected = id;
|
||||
this.updateInfo();
|
||||
this.request();
|
||||
}
|
||||
|
||||
private updateInfo() {
|
||||
const id = this.opts.selected;
|
||||
const b = id ? this.world.buildings.find((x) => x.id === id) : null;
|
||||
if (!b) {
|
||||
this.info.hidden = true;
|
||||
return;
|
||||
}
|
||||
this.info.hidden = false;
|
||||
const cur = this.opts.overrides.has(b.id) ? this.opts.overrides.get(b.id)! : this.opts.floor;
|
||||
const lvl = cur !== null && cur < b.levels ? cur : null;
|
||||
const f = lvl !== null ? b.floors[lvl] : null;
|
||||
const src = b.levelsSource === 'osm' ? 'из OSM' : b.levelsSource === 'height' ? 'по высоте' : 'оценка';
|
||||
const counts = new Map<string, number>();
|
||||
if (f) for (const r of f.rooms) if (ROOM_NAME[r.kind]) counts.set(ROOM_NAME[r.kind], (counts.get(ROOM_NAME[r.kind]) ?? 0) + 1);
|
||||
const roomList = [...counts.entries()].map(([k, n]) => (n > 1 ? `${k} ×${n}` : k)).join(', ');
|
||||
const homes = b.units.filter((u) => u.kind === 'home').length;
|
||||
const osm = b.osmId ? `<a href="https://www.openstreetmap.org/${b.osmId}" target="_blank" rel="noopener">OSM</a>` : '';
|
||||
this.info.innerHTML = `
|
||||
<button class="close" title="Закрыть">×</button>
|
||||
<div class="title">${esc(b.name || KIND_NAME[b.kind])}</div>
|
||||
<div class="sub">${b.name ? esc(KIND_NAME[b.kind]) + ' · ' : ''}${b.use ? esc(b.use) + ' · ' : ''}${b.address ? esc(b.address) : ''}</div>
|
||||
<div class="kv"><span>Этажей</span><b>${b.levels}</b> <i>${src}</i></div>
|
||||
<div class="kv"><span>Площадь застройки</span><b>${Math.round(b.area)} м²</b></div>
|
||||
${homes ? `<div class="kv"><span>Жилых помещений</span><b>${homes}</b></div>` : ''}
|
||||
${b.beds ? `<div class="kv"><span>Спальных мест</span><b>${b.beds}</b></div>` : ''}
|
||||
<div class="floor-pick">${['⌂', ...Array.from({ length: Math.min(b.levels, 40) }, (_, i) => String(i + 1))].map((t, i) => `<button class="floor-btn sm${(i === 0 && lvl === null) || lvl === i - 1 ? ' on' : ''}" data-l="${i - 1}">${t}</button>`).join('')}</div>
|
||||
${f ? `<div class="rooms">${f.rooms.length} помещений: ${esc(roomList)}</div>` : ''}
|
||||
<div class="links">${osm}<a href="#" data-a="zoom">Приблизить</a></div>`;
|
||||
this.info.querySelector('.close')!.addEventListener('click', () => this.select(null));
|
||||
this.info.querySelectorAll<HTMLButtonElement>('[data-l]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => {
|
||||
const l = +btn.dataset.l!;
|
||||
this.opts.overrides.set(b.id, l < 0 ? null : l);
|
||||
this.updateInfo();
|
||||
this.request();
|
||||
}));
|
||||
this.info.querySelector('[data-a=zoom]')!.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const c = centroid(b.footprint);
|
||||
const span = Math.sqrt(b.area) * 1.8 + 10;
|
||||
const r = this.root.getBoundingClientRect();
|
||||
this.lookAt(c, Math.min(60, Math.min(r.width, r.height) / span));
|
||||
if (this.opts.overrides.get(b.id) == null && this.opts.floor === null) this.opts.overrides.set(b.id, 0);
|
||||
this.updateInfo();
|
||||
});
|
||||
}
|
||||
|
||||
selectBuilding(b: Building) {
|
||||
this.select(b.id);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user