Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 134 additions & 5 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion PROJECT_FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@
Угловые столбы и пояс по линии карниза мансарды в `pieces` не входят: столбы
считаются уголками на стороне расчёта.

Куски ФРОНТОНА выходят за длину своей стены: треугольник фронтона шире неё на
свес кровли с каждой стороны (300 мм в наших домах), поэтому его `x` начинается
с −300, а заканчивается на «длина стены + 300». Отрицательный `x` здесь —
нормальные данные, а не сбой.

## Пример

```json
Expand Down Expand Up @@ -144,4 +149,10 @@
- `house.typeId` — строка; `areaM2` и `foundationMm` положительные.
- В `elements` допустимы только известные разделы; внутри — типы полей из таблиц
выше, все размеры неотрицательные целые.
- `pieces[].vertices` — минимум три точки, координаты неотрицательные.
- `pieces[].vertices` — минимум три точки. `y` неотрицательный (считается от низа
стен первого этажа), а вот **`x` у фронтона бывает отрицательным**: треугольник
фронтона шире своей стены на свес кровли с каждой стороны, поэтому его куски
идут от −свес до «длина стены + свес» (в наших домах свес 300 мм, размах
−300 … 8046 при стене 7746). Это не ошибка данных: ровно так фронтон построен
в сцене и так же он отдаётся в `calculate_facade`. Требовать `x ≥ 0` нельзя —
на таком проекте заявка не сохранится.
16 changes: 15 additions & 1 deletion ResourceManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -557,19 +557,33 @@ class ResourceManager {
// calculation — необязательные данные для сметы: то же тело, что у
// calculation_report. Передали — клиенту придёт письмо со сметой в PDF,
// не передали — письмо уйдёт с одной ссылкой на проект.
// Отличие от копии бэкенда: причину отказа не выбрасываем. Сервер присылает
// её текстом ({"error": "Неверный формат проекта — elements.fence.points[0].x:
// нужно число"}), а пользователь видел только «попробуйте ещё раз» — понять,
// что именно не так, было нельзя (правка 2026-09-20). Ответ кладётся в
// lastSaveError; возвращаемое значение прежнее.
lastSaveError = null;

async saveProject(name, email, data, calculation = null) {
this.lastSaveError = null;
try {
const response = await fetch(`${this.#api_domain}/api/v1/create_project/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, data, calculation }),
});
if (!response.ok) {
console.error(`HTTP ${response.status}`);
let text = '';
try { text = await response.text(); } catch (_) {}
let message = '';
try { message = (JSON.parse(text) || {}).error || ''; } catch (_) {}
this.lastSaveError = { status: response.status, message, body: text };
console.error(`[create_project] HTTP ${response.status}`, message || text);
return null;
}
return await response.json();
} catch (error) {
this.lastSaveError = { status: 0, message: '', body: String(error) };
console.error('Failed to save project:', error);
return null;
}
Expand Down
39 changes: 39 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,42 @@
- [x] Менеджер ресурсов ревизии 2026-09-20: у `saveProject` появилось поле
`calculation` (по нему к письму прикладывается смета в PDF) — шлём его;
добавлен `saveProjectTest` для отладки без писем.
- [x] Отказ сервера в заявке показывается пользователю (текст сервера, отдельные
сообщения у 429 и 413) и пишется в консоль; если забракована только смета
(`calculation`), заявка уходит повторно без неё.
- [x] Запрос заявки выгружается в JSON-файл кнопкой в окне (появляется после
отказа сервера): тело, ответ сервера и откуда отправляли.
- [ ] **Бэкенд:** валидатор отказывает, если у куска фронтона `x` отрицательный.
Так и должно быть: треугольник фронтона шире стены на свес кровли
(−300 … длина стены + 300). Требование «координаты неотрицательные»
относится только к `y` — уточнено в `PROJECT_FORMAT.md`.

### Правки 2026-09-26

Баги:

- [x] Отделка фасада не заползает на соседнюю стену: угловой столб красится по граням.
- [x] Уголок фронтона над угловым модулем (столб на уровне карниза мансарды) красится.
- [x] При загрузке проекта не появляются ступени, которых не строили.
- [x] Коллизии грядок: невидимая заготовка ступеней больше не мешает, поворот
проверяется.

Изменения:

- [x] «Высота террасы» вместо «Высота настила».
- [x] У террасы по умолчанию выбрана «Пустотелая».
- [x] Минимальный размер террасы 1×1 м.
- [x] Плашка размеров под объектом / сбоку от линии, ручки не закрывает.
- [x] Ступени по умолчанию той же фирмы и цвета, что терраса; выбор не отключён.
- [x] «Удалить всё» без окна подтверждения.
- [x] Дорожки и забор рисуются непрерывно до правой кнопки (или Esc, или повторного
клика в последнюю точку); клик по точке только выделяет её; подсказки обновлены.
- [x] Забор не проходит через дом и террасу.
- [x] Дорожка тоже не проходит через дом и террасу, но подходит к ним вплотную.
- [ ] **Бэкенд:** фильтр «Заполнение: Плетёные / Доски ДПК» у забора — нужна
характеристика товара.
- [ ] **Бэкенд:** фильтр «Вертикальная / Горизонтальная» у фасада — нужна
характеристика товара.
- [x] «Терраса у бассейна» убрана из интерфейса.
- [x] Галочка согласия в заявке со ссылкой на политику; без неё «Отправить»
заблокирована.
125 changes: 119 additions & 6 deletions canvas.js
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,90 @@ function _fenceTooClose(p) {
return false;
}

// Пересекаются ли отрезки плана ab и cd (касание концом тоже считаем).
function _planSegsCross(a, b, c, d) {
const cr = (p, q, r) => (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x);
const d1 = cr(a, b, c), d2 = cr(a, b, d), d3 = cr(c, d, a), d4 = cr(c, d, b);
return ((d1 > 0) !== (d2 > 0)) && ((d3 > 0) !== (d4 > 0));
}

function _planPointInPoly(p, poly) {
let hit = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[i], b = poly[j];
if ((a.y > p.y) !== (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x) hit = !hit;
}
return hit;
}

// Контуры, через которые не проходит линия забора: дом и террасы.
function _fenceBlockerPolys() {
const out = [];
if (typeof isEmptyLot !== 'function' || !isEmptyLot()) {
const hp = getHousePolygonNorm();
if (hp && hp.corners && hp.corners.length >= 3) out.push(hp.corners);
}
for (const sec of ['terrace', 'pool_terrace']) {
for (const r of (typeof secRects === 'function' ? secRects(sec) : [])) {
if (!r || r.w <= 0 || r.h <= 0) continue;
out.push([{ x: r.x, y: r.y }, { x: r.x + r.w, y: r.y },
{ x: r.x + r.w, y: r.y + r.h }, { x: r.x, y: r.y + r.h }]);
}
}
return out;
}

// Отрезок забора ab недопустим: проходит сквозь дом или террасу, либо подходит
// к ним ближе FENCE_MIN_CLEAR (правка 2026-09-26). Концы проверяет
// _fenceTooClose, но отрезок между двумя разрешёнными точками мог пересечь дом
// насквозь. Возвращает 'cross' | 'near' | null.
function fenceSegBlocked(a, b) {
const lim = FENCE_MIN_CLEAR / GRID;
for (const poly of _fenceBlockerPolys()) {
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
if (_planPointInPoly(mid, poly)) return 'cross';
for (let i = 0; i < poly.length; i++) {
const c = poly[i], d = poly[(i + 1) % poly.length];
if (_planSegsCross(a, b, c, d)) return 'cross';
}
// Ближе допуска: расстояние между отрезками — наименьшее из расстояний
// от концов одного до другого (пересечения уже отсеяны выше).
for (let i = 0; i < poly.length; i++) {
const c = poly[i], d = poly[(i + 1) % poly.length];
if (Math.min(_planDistToSeg(c, a, b), _planDistToSeg(d, a, b),
_planDistToSeg(a, c, d), _planDistToSeg(b, c, d)) < lim - 1e-9) return 'near';
}
}
return null;
}

// Отрезок ОСИ дорожки ab проходит через дом или террасу (правка 2026-09-26:
// пересечение запрещено, как у забора). В отличие от забора, подходить к ним
// вплотную можно: дорожка ведёт к террасе и к крыльцу, а вдоль стены идёт
// отмосткой. Поэтому проверяем только, заходит ли ось ВНУТРЬ контура — точки
// оси с шагом 10 см, без самих концов (конец на кромке — это примыкание).
// Полоса по ширине, которая всё же легла под стену или настил, не строится
// (pathLinesVisible).
const PATH_PROBE_STEP_M = 0.1;

function pathSegBlocked(a, b) {
const len = Math.hypot(b.x - a.x, b.y - a.y);
if (len < 1e-9) return false;
const n = Math.max(2, Math.ceil(len * GRID / PATH_PROBE_STEP_M));
const eps = 0.02 / GRID; // 2 см: кромку считаем снаружи
for (const poly of _fenceBlockerPolys()) {
for (let i = 1; i < n; i++) {
const t = i / n;
const q = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
if (!_planPointInPoly(q, poly)) continue;
let edge = Infinity;
for (let k = 0; k < poly.length; k++) edge = Math.min(edge, _planDistToSeg(q, poly[k], poly[(k + 1) % poly.length]));
if (edge > eps) return true;
}
}
return false;
}

function _planDistToSeg(p, a, b) {
const dx = b.x - a.x, dy = b.y - a.y, l2 = dx * dx + dy * dy;
if (l2 < 1e-12) return Math.hypot(p.x - a.x, p.y - a.y);
Expand Down Expand Up @@ -511,7 +595,12 @@ function _collideBlockers(secId, idx) {
const list = (typeof secRects === 'function') ? secRects(sec) : [];
list.forEach((r, i) => { if (r && !(sec === secId && i === idx)) add(r.x, r.y, r.w, r.h); });
}
if (!_collideIgnores(secId, 'steps')) {
// Ступени мешают, только если раздел ВКЛЮЧЁН: в состоянии всегда лежит их
// заготовка (DEFAULT_STEPS_RECT) — невидимая, но раньше она участвовала в
// проверке. Грядка по умолчанию вставала прямо на неё, считалась «в коллизии»
// с самого начала и упиралась в пустое место (баг 2026-09-26). Сама лестница
// проверяет себя всегда: её раздел в этот момент открыт.
if (!_collideIgnores(secId, 'steps') && (secId === 'steps' || S.sections.includes('steps'))) {
const list = (typeof stepsAll === 'function') ? stepsAll() : [];
list.forEach((st, i) => { if (st && !(secId === 'steps' && i === idx)) add(st.x, st.y, st.w, st.h); });
}
Expand Down Expand Up @@ -2273,8 +2362,14 @@ function _nearestTarget(coord, targets) {
// Принцип: к стене/террасе липнет ТОЛЬКО движущаяся кромка; противоположная остаётся
// на сетке. Поэтому wall-snap НЕ перетирается финальным snapNorm, и дальние углы
// не уносит с сетки (исправление «снапается целиком»).
// Минимальная сторона прямоугольника по разделам, м. Террасу меньше 1×1 м не
// строят (правка 2026-09-26), остальным хватает шага снапа. Берётся только при
// ЯВНО переданном secId: ступени и грядки зовут без него, и прежде по умолчанию
// снапались как терраса — минимум террасы им не нужен.
const RECT_MIN_M = { terrace: 1.0, pool_terrace: 1.0 };

function snapDraggedRect(kind, ds, dx, dy, excludeIdx, secId) {
const mn = SNAP / GRID;
const mn = Math.max(SNAP, (secId && RECT_MIN_M[secId]) || 0) / GRID;
const { xs, ys } = _snapTargets(excludeIdx, secId);

if (kind === 'move') {
Expand Down Expand Up @@ -2957,14 +3052,32 @@ function delActiveBed() {
}

// Поворот активной грядки на 90° вокруг её центра (swap w↔h).
// Развёрнутая грядка не должна налезть на соседей: раньше поворот коллизии не
// проверял вовсе, и грядка у стены или у соседней грядки разворачивалась прямо
// в неё (баг 2026-09-26). Сначала пробуем поворот вокруг центра, затем — вокруг
// каждого из углов (грядка «перекладывается» от препятствия). Мешает везде —
// остаётся как была, с подсказкой.
function rotateActiveBed() {
if (S.activeBed === null || !S.beds[S.activeBed]) return;
const b = S.beds[S.activeBed];
const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
const nw = b.h, nh = b.w;
const c = _clampBedPos(snapNorm(cx - nw / 2), snapNorm(cy - nh / 2), nw, nh);
b.w = nw; b.h = nh; b.x = c.x; b.y = c.y;
_secChanged('beds');
const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
const tries = [
{ x: cx - nw / 2, y: cy - nh / 2 }, // вокруг центра
{ x: b.x, y: b.y }, // от левого верхнего угла
{ x: b.x + b.w - nw, y: b.y }, // от правого верхнего
{ x: b.x, y: b.y + b.h - nh }, // от левого нижнего
{ x: b.x + b.w - nw, y: b.y + b.h - nh }, // от правого нижнего
];
for (const t of tries) {
const c = _clampBedPos(snapNorm(t.x), snapNorm(t.y), nw, nh);
const r = { x: c.x, y: c.y, w: nw, h: nh };
if (typeof rectCollides === 'function' && rectCollides(r, 'beds', S.activeBed)) continue;
b.w = nw; b.h = nh; b.x = c.x; b.y = c.y;
_secChanged('beds');
return;
}
if (typeof dToast === 'function') dToast('Здесь грядку не развернуть — мешают соседние объекты');
}

function hitBeds(wx, wy, W) {
Expand Down
Loading
Loading