diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5ad00fe..529f946 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -182,11 +182,11 @@ viewer3d-core/builders/railing — classic scripts с общей глобаль версию поднимать обязательно. Актуальный срез (совпадает с `index.html`): ``` -styles-desktop.css?v=47 styles-mobile.css?v=2 state.js?v=74 -canvas.js?v=63 shared/house-builder.js?v=89 ResourceManager.js?v=11 -viewer3d-core.js?v=160 viewer3d-builders.js?v=65 viewer3d-railing.js?v=18 -viewer3d-entourage.js?v=14 editor3d.js?v=17 nav-desktop.js?v=134 -nav-mobile.js?v=3 project-io.js?v=4 backend_API/Calculator.js?v=3 +styles-desktop.css?v=49 styles-mobile.css?v=3 state.js?v=75 +canvas.js?v=65 shared/house-builder.js?v=89 ResourceManager.js?v=12 +viewer3d-core.js?v=161 viewer3d-builders.js?v=65 viewer3d-railing.js?v=18 +viewer3d-entourage.js?v=14 editor3d.js?v=19 nav-desktop.js?v=137 +nav-mobile.js?v=5 project-io.js?v=7 backend_API/Calculator.js?v=3 ``` `viewer3d-entourage.js` автоматически детектит `IS_MOBILE` (UA + `innerWidth<768`) @@ -947,6 +947,135 @@ JSON-контракта `POST /api/calculate` и схемы БД лежит в g - В «Порядок подключения скриптов» добавлен актуальный срез `?v=N`, чтобы версии не искать по журналу; в решения — две строки про снап ограждения. +Ветка **Mobile**: правки 2026-09-26 — фасад на углах, рисование линий, мелочи. + +- **Угловой столб фасада красится по граням.** Столб — один меш на оба фасада + угла, и красился целиком: отделка одной стены выходила на грань соседней. + Теперь его геометрия один раз перекладывается по группам граней + (`_facadeFaceGroups`: ±X, ±Z, прочие), а грань берёт отделку той стены, в чьей + плоскости лежит (`_adjAlongX` / `_adjAlongZ` — сторону соседа сбоку даёт + смещение центров). В смету грань идёт половиной площади столба в свою отделку. +- **Уголок фронтона над угловым модулем.** Угловой столб на уровне карниза + мансарды (высотой 0.2 м) не красился НИКОГДА: сбоку у него только пояс карниза + (он сам не сегмент), а фронтон сверху и простенок снизу касаются его торцами. + Теперь грань в плоскости пояса следует за поясом (`_followAlongX/Z`, пояса + красятся первыми), а грань в плоскости фронтона — за фронтоном над столбом или + простенком под ним (правило «сверху/снизу», как у самого пояса). +- **Линии рисуются непрерывно.** Первый клик начинает дорожку или забор, каждый + следующий добавляет вершину; закончить — правой кнопкой (клик без протяжки; + протяжка правой по-прежнему двигает вид), Esc или повторным кликом в последнюю + точку (на телефоне правой кнопки нет). Клик по готовой точке, пока ничего не + рисуем, только выделяет её — раньше он заводил новую линию, и чтобы + передвинуть или удалить точку, приходилось сбрасывать построение Esc. Во время + рисования клик по точке соединяет с ней; начало своей же линии замыкает контур + и заканчивает рисование. Меню браузера по правой кнопке над видом подавлено. + Подсказки переписаны, их редакция вошла в ключ памяти (`D_HINT_REV`), чтобы + на телефоне новую подсказку увидели и те, кто видел старую. +- **Забор не проходит через дом и террасу** (`fenceSegBlocked`): отрезок + проверяется на пересечение контуров и на те же 3 м допуска, что и точки; + при протяжке точка забора упирается. +- **Дорожка тоже не проходит через дом и террасу** (`pathSegBlocked`; 19.09 было + решено наоборот — вести насквозь и обрезать скрытое). В отличие от забора, + подходить к ним вплотную можно: дорожка к ним и ведёт. Проверяется только ось — + заходит ли она ВНУТРЬ контура (точки через 10 см, без концов, кромка считается + снаружи). Полоса по ширине, легшая под стену или настил, по-прежнему не + строится (`pathLinesVisible`) — это же выручает старые проекты с дорожкой + насквозь. Замеры: насквозь через дом и через террасу — отказ; к стене и к + террасе вплотную, вдоль стены и мимо — можно. +- **Коллизии грядок.** Невидимая заготовка ступеней (`DEFAULT_STEPS_RECT` лежит в + состоянии всегда) участвовала в проверке, даже когда ступеней нет: грядка по + умолчанию вставала прямо на неё и считалась «в коллизии» с самого начала. + Теперь ступени мешают, только если раздел включён. Поворот грядки теперь тоже + проверяется: сначала вокруг центра, затем вокруг каждого угла; мешает везде — + грядка остаётся как была, с подсказкой. +- **Ступени в снимке проекта** пишутся, только если раздел включён — иначе + заготовка уходила в описание и при открытии по ссылке появлялись ступени, + которых никто не строил. То же для ограждения. Проекты, сохранённые до правки, + лишние ступени сохранят. +- **Ступени по умолчанию — той же фирмы и цвета, что доска террасы** + (`_syncDefaultStepsProduct`): сперва товар из `default_steps_id`, если совпал; + иначе поиск в каталоге ступеней по фирме (первое слово названия — поля + производителя в API нет) и цвету; иначе `default_steps_id` как есть. Выбор + подбирается и при открытии раздела ступеней, попадает в смету, ручной выбор не + перетирает. Каталог для подбора берётся БЕЗ пользовательских фильтров + (`_ensureCatalogSection(id, {raw: true})`): фильтры активной террасы («Пустотелая») + отсекли бы полнотелые ступени. +- **Плашки размеров не лежат на объекте**: прямоугольник подписан под своим + экранным габаритом (у края кадра — над ним), отрезок — сбоку, со сдвигом + поперёк линии (`E3D_LABEL_GAP`). +- Прочее: «Высота террасы» вместо «Высота настила»; у террасы по умолчанию выбрана + «Пустотелая» (`CAT_FILTER_DEFAULTS`); минимальная сторона террасы 1 м + (`RECT_MIN_M`, только для явно переданного раздела — ступени и грядки зовут + снап без него); «Удалить всё» без окна подтверждения (заодно починена + скобка: `S.activeSteps = 0` сбрасывался при очистке любого раздела); «Терраса у + бассейна» скрыта из меню (`hidden`, код раздела сохранён); в заявке галочка + согласия со ссылкой на политику, без неё «Отправить» заблокирована. +- **Не сделано — нужен бэкенд:** фильтры «Заполнение: Плетёные / Доски ДПК» у + забора и «Вертикальная / Горизонтальная» у фасада. В API нет характеристик, + по которым их отбирать (есть только `price_category` и `core_type`). +- Замеры: одна стена под отделкой — у соседней грани угла полосы больше нет; у + мансарды (type_10) угловой столб на уровне карниза окрашен вместе с фасадом. + Дорожка — 4 клика дают одну ломаную из 4 точек, правая кнопка заканчивает, клик + по точке выделяет её, Delete удаляет. Забор через дом — отказ «Забор не может + проходить через дом или террасу», точка при протяжке упирается. Грядка по + умолчанию больше не «в коллизии», поворот у соседа уходит на свободный угол. + Ступени под доску TalverWood «Венге» — TalverWood «Венге» (а не «Серый» из + `default_steps_id`), с ценой в смете; ручной выбор сохраняется. Терраса не + меньше 1×1 м. Плашка террасы — на 14 px ниже нижнего угла, ни одна ручка под + ней. Галочка: без неё кнопка заблокирована. +- Cache-bust: `state.js?v=75`, `canvas.js?v=65`, `viewer3d-core.js?v=161`, + `editor3d.js?v=19`, `nav-desktop.js?v=137`, `nav-mobile.js?v=5`, + `project-io.js?v=7`, `styles-desktop.css?v=49`, `styles-mobile.css?v=3`. + +Ветка **Mobile**: запрос заявки выгружается в файл. + +- **Кнопка «Сохранить запрос в файл»** появляется в окне заявки ТОЛЬКО после + отказа сервера (`pioDumpRequest`). В файле три части: `request` — тело как + есть (можно послать серверу повторно), `response` — ответ (`status: 0` значит, + что ответа не было вовсе: сеть, CORS или блокировка), `meta` — страница, + домен API, адрес запроса, версия формата, браузер. Тело берётся из последней + попытки (`_pioLastAttempt`), а не собирается заново — в файле лежит ровно то, + что ушло. +- **Найдена причина отказа на больших проектах**: у кусков ФРОНТОНА координата + `x` отрицательная. Треугольник фронтона шире своей стены на свес кровли с + каждой стороны, поэтому его куски идут от −300 до «длина стены + 300» (замер: + размах −300 … 8046 при стене 7746 мм). Спецификация требовала неотрицательных + координат — на таком проекте валидатор сервера отказывает. Геометрия при этом + верна: ровно так фронтон построен в сцене и так же уходит в `calculate_facade`. + `PROJECT_FORMAT.md` поправлен: `y` неотрицательный, `x` у фронтона — нет. +- Размер тела заявки на предельном проекте (131 сегмент фасада, обе отделки, 40 + точек забора, 40 точек дорожек, 10 грядок, 20 предметов мебели) — 46 КБ: + `data` 21.6 КБ, `calculation` 24.3 КБ. До лимита в 1 МБ далеко, 413 можно не + подозревать. +- Замеры: при отказе «Неверный формат проекта — …» окно показывает текст сервера + и кнопку выгрузки; файл `shpuk-request-<время>.json` содержит `request` с + полями `name/email/data/calculation`, `response` с кодом и телом ответа и + `meta` с адресом запроса. +- Cache-bust: `project-io.js?v=6`, `nav-desktop.js?v=135`, `styles-desktop.css?v=48`. + +Ветка **Mobile**: заявка объясняет отказ сервера. + +- **Причина отказа больше не теряется.** Сервер присылает её текстом + (`{"error": "Неверный формат проекта — elements.fence.points[0].x: нужно + число"}`), а менеджер ресурсов выбрасывал тело ответа и возвращал `null` — + пользователь видел «Не удалось отправить заявку. Попробуйте ещё раз» и понять + ничего не мог. Теперь ответ разбирается и кладётся в `lastSaveError` + (`{status, message, body}`), окно заявки показывает текст сервера, а консоль — + код и тело. Отдельные тексты у 429 (слишком часто, лимит 20 в час) и 413 + (проект больше мегабайта). Это единственное расхождение рабочей копии + `ResourceManager.js` с копией бэкенда. +- **Заявка не пропадает из-за сметы.** Если сервер забраковал именно + `calculation` (он проверяет его правилами расчёта), заявка уходит повторно без + этого поля: клиент получает письмо со ссылкой на проект, а причина остаётся в + консоли. Ссылка — суть заявки, PDF — дополнение. +- Замеры (отказ подменён в тесте): при «Неверный формат проекта — …» окно + показывает текст сервера и остаётся открытым; при отказе по `calculation` + уходит второй запрос уже без неё и появляется подтверждение; при 429 — «слишком + много заявок подряд»; при успехе — как раньше, одно обращение. +- Размер описания проекта со всеми разделами — 2.5 КБ, до лимита в мегабайт + далеко: 413 в реальном отказе можно не подозревать. +- Cache-bust: `ResourceManager.js?v=12`, `project-io.js?v=5`. + Ветка **Mobile**: менеджер ресурсов ревизии 2026-09-20. - **`saveProject(name, email, data, calculation)`** — у заявки появилось diff --git a/PROJECT_FORMAT.md b/PROJECT_FORMAT.md index fc1e860..b877105 100644 --- a/PROJECT_FORMAT.md +++ b/PROJECT_FORMAT.md @@ -92,6 +92,11 @@ Угловые столбы и пояс по линии карниза мансарды в `pieces` не входят: столбы считаются уголками на стороне расчёта. +Куски ФРОНТОНА выходят за длину своей стены: треугольник фронтона шире неё на +свес кровли с каждой стороны (300 мм в наших домах), поэтому его `x` начинается +с −300, а заканчивается на «длина стены + 300». Отрицательный `x` здесь — +нормальные данные, а не сбой. + ## Пример ```json @@ -144,4 +149,10 @@ - `house.typeId` — строка; `areaM2` и `foundationMm` положительные. - В `elements` допустимы только известные разделы; внутри — типы полей из таблиц выше, все размеры неотрицательные целые. -- `pieces[].vertices` — минимум три точки, координаты неотрицательные. +- `pieces[].vertices` — минимум три точки. `y` неотрицательный (считается от низа + стен первого этажа), а вот **`x` у фронтона бывает отрицательным**: треугольник + фронтона шире своей стены на свес кровли с каждой стороны, поэтому его куски + идут от −свес до «длина стены + свес» (в наших домах свес 300 мм, размах + −300 … 8046 при стене 7746). Это не ошибка данных: ровно так фронтон построен + в сцене и так же он отдаётся в `calculate_facade`. Требовать `x ≥ 0` нельзя — + на таком проекте заявка не сохранится. diff --git a/ResourceManager.js b/ResourceManager.js index dc8a03f..0404f77 100644 --- a/ResourceManager.js +++ b/ResourceManager.js @@ -557,7 +557,15 @@ 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', @@ -565,11 +573,17 @@ class ResourceManager { 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; } diff --git a/TODO.md b/TODO.md index a2e3cb4..b46063e 100644 --- a/TODO.md +++ b/TODO.md @@ -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] Галочка согласия в заявке со ссылкой на политику; без неё «Отправить» + заблокирована. diff --git a/canvas.js b/canvas.js index a006903..498bbd4 100644 --- a/canvas.js +++ b/canvas.js @@ -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); @@ -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); }); } @@ -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') { @@ -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) { diff --git a/editor3d.js b/editor3d.js index 5c4efe4..22e0e0a 100644 --- a/editor3d.js +++ b/editor3d.js @@ -594,7 +594,9 @@ function e3dSync() { function _e3dOverlayEl() { return document.getElementById('d-3d-overlay'); } -let _e3dLabels = []; // [{ np, text, cls }] — что показывать в этом кадре +// Что показывать в этом кадре: [{ text, corners }] — прямоугольник (подпись под +// ним), [{ text, a, b }] — отрезок (подпись сбоку), [{ text, np }] — точка. +let _e3dLabels = []; function _fmtM3d(m) { return (Math.round(m * 100) / 100).toFixed(m < 10 ? 2 : 1).replace(/\.?0+$/, '') + ' м'; @@ -617,7 +619,11 @@ function _e3dSyncOverlay() { const txt = kind === 'rect' ? `${_fmtM3d(wm)} × ${_fmtM3d(hm)} · ${(wm * hm).toFixed(1)} м²` : `${_fmtM3d(wm)} × ${_fmtM3d(hm)}`; - _e3dLabels.push({ np: { x: r.x + r.w / 2, y: r.y + r.h / 2 }, text: txt }); + // Подпись ставится ПОД объектом, а не на нём: над центром она закрывала + // угловые ручки, особенно у маленькой террасы и при виде сверху издалека. + _e3dLabels.push({ text: txt, corners: [ + { 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 }] }); }); } else if (kind === 'railing') { // Ширина разрыва — таким же числом, как остальные размеры: на глаз её было @@ -626,8 +632,7 @@ function _e3dSyncOverlay() { for (const e of railingEntryPointsNorm()) { const len = Math.hypot(e.b.x - e.a.x, e.b.y - e.a.y) * GRID; if (len < 0.05) continue; - _e3dLabels.push({ np: { x: (e.a.x + e.b.x) / 2, y: (e.a.y + e.b.y) / 2 }, - text: _fmtM3d(len) }); + _e3dLabels.push({ a: e.a, b: e.b, text: _fmtM3d(len) }); } } } else if (kind === 'line') { @@ -638,7 +643,7 @@ function _e3dSyncOverlay() { const a = seg[i - 1], b = seg[i]; const len = Math.hypot(b.x - a.x, b.y - a.y) * GRID; if (len < 0.3) continue; - _e3dLabels.push({ np: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, text: _fmtM3d(len) }); + _e3dLabels.push({ a, b, text: _fmtM3d(len) }); } } } @@ -657,6 +662,18 @@ function _e3dSyncOverlay() { // Проекция подписей в экранные координаты — вызывается и из кадра анимации. const _e3dProj = new THREE.Vector3(); +// Подписи НЕ садятся на сам объект (правка 2026-09-26): над центром они закрывали +// ручки. Прямоугольник подписывается под своим экранным габаритом (у края кадра — +// над ним), отрезок — сбоку, со сдвигом поперёк линии на экране. Зазор берётся +// с запасом на радиус ручки. +const E3D_LABEL_GAP = 14; // px от объекта до края плашки + +function _e3dScreen(np, W, H) { + const w = _e3dToWorld(np); + _e3dProj.set(w.x, E3D_LIFT, w.z).project(threeState.camera); // та же отметка, что маркеры + return { x: (_e3dProj.x * 0.5 + 0.5) * W, y: (-_e3dProj.y * 0.5 + 0.5) * H, behind: _e3dProj.z > 1 }; +} + function _e3dPlaceLabels() { const host = _e3dOverlayEl(); if (!host || !threeState || !_e3dLabels.length) return; @@ -665,12 +682,36 @@ function _e3dPlaceLabels() { _e3dLabels.forEach((l, i) => { const node = host.children[i]; if (!node) return; - const w = _e3dToWorld(l.np); - _e3dProj.set(w.x, E3D_LIFT, w.z).project(threeState.camera); // подписи на той же отметке, что маркеры - if (_e3dProj.z > 1) { node.style.display = 'none'; return; } + const lw = node.offsetWidth || 0, lh = node.offsetHeight || 0; + let cx, cy; + if (l.corners) { + const pts = l.corners.map(c => _e3dScreen(c, W, H)); + if (pts.some(p => p.behind)) { node.style.display = 'none'; return; } + const xs = pts.map(p => p.x), ys = pts.map(p => p.y); + cx = (Math.min(...xs) + Math.max(...xs)) / 2; + const below = Math.max(...ys) + E3D_LABEL_GAP + lh / 2; + const above = Math.min(...ys) - E3D_LABEL_GAP - lh / 2; + cy = (below + lh / 2 <= H || above - lh / 2 < 0) ? below : above; + } else if (l.a && l.b) { + const pa = _e3dScreen(l.a, W, H), pb = _e3dScreen(l.b, W, H); + if (pa.behind || pb.behind) { node.style.display = 'none'; return; } + const dx = pb.x - pa.x, dy = pb.y - pa.y, len = Math.hypot(dx, dy) || 1; + // Нормаль к отрезку на экране — в сторону «вниз» (или вправо у вертикали), + // чтобы подписи вдоль ломаной ложились на одну сторону. + let nx = -dy / len, ny = dx / len; + if (ny < 0 || (Math.abs(ny) < 1e-3 && nx < 0)) { nx = -nx; ny = -ny; } + // Сдвиг — на полгабарита плашки поперёк линии плюс зазор. + const reach = Math.abs(nx) * lw / 2 + Math.abs(ny) * lh / 2 + E3D_LABEL_GAP; + cx = (pa.x + pb.x) / 2 + nx * reach; + cy = (pa.y + pb.y) / 2 + ny * reach; + } else { + const p = _e3dScreen(l.np, W, H); + if (p.behind) { node.style.display = 'none'; return; } + cx = p.x; cy = p.y; + } node.style.display = ''; - node.style.left = ((_e3dProj.x * 0.5 + 0.5) * W) + 'px'; - node.style.top = ((-_e3dProj.y * 0.5 + 0.5) * H) + 'px'; + node.style.left = cx + 'px'; + node.style.top = cy + 'px'; }); } @@ -779,8 +820,20 @@ function _e3dDragMove(np) { const snapTo = (typeof _lineCloseTarget === 'function') ? _lineCloseTarget(sec, d.idx, q) : null; if (snapTo) q = { x: snapTo.x, y: snapTo.y }; else if (sec === 'fence' && typeof _fenceTooClose === 'function' && _fenceTooClose(q)) return; - // Дорожке пересечения разрешены (ТЗ 2026-09-19): полосу можно вести сквозь - // дом и настил, скрытая часть просто не строится (pathLinesVisible). + // Точка забора тянет за собой два своих отрезка — ни один не должен пройти + // сквозь дом или террасу (правка 2026-09-26): точка упирается, как объекты. + if (sec === 'fence' && typeof fenceSegBlocked === 'function') { + for (const nb of [pts[d.idx - 1], pts[d.idx + 1]]) { + if (nb && !nb.break && fenceSegBlocked(nb, q)) return; + } + } + // Дорожке пересекать дом и террасу тоже нельзя (правка 2026-09-26; 19.09 + // было решено наоборот — вести насквозь и обрезать скрытое). + if (sec === 'paths' && typeof pathSegBlocked === 'function') { + for (const nb of [pts[d.idx - 1], pts[d.idx + 1]]) { + if (nb && !nb.break && pathSegBlocked(nb, q)) return; + } + } pt.x = q.x; pt.y = q.y; } e3dSync(); // сцену НЕ пересобираем: тяжёлая сборка идёт один раз, на отпускании @@ -863,7 +916,14 @@ function _e3dLineCommit(name, a, b) { _e3dLineWrite(name, segs); } -// Клик по земле в разделе линии: начать или закончить отрезок. +// Рисование линии НЕПРЕРЫВНОЕ (правка 2026-09-26): первый клик начинает линию, +// каждый следующий добавляет вершину, и линия тянется дальше, пока её не +// закончат — правой кнопкой мыши, Esc или повторным кликом в последнюю точку +// (на телефоне правой кнопки нет). Клик по существующей точке, пока ничего не +// рисуем, только выделяет её: раньше он сразу заводил новую линию, и чтобы +// передвинуть или удалить точку, приходилось сперва сбрасывать построение +// через Esc. Во время рисования клик по существующей точке соединяет с ней; +// если это начало самой рисуемой линии — контур замкнут, рисование кончается. function _e3dDrawClick(np) { const name = E3D.sec; const p = _e3dDrawPoint(name, np); @@ -871,17 +931,52 @@ function _e3dDrawClick(np) { if (typeof dToast === 'function') dToast('Ближе 3 м к дому и террасе забор не ставится'); return; } - if (!E3D.draw || E3D.draw.name !== name) { E3D.draw = { name, start: p, cursor: null }; e3dSync(); return; } + if (!E3D.draw || E3D.draw.name !== name) { + E3D.draw = { name, start: { x: p.x, y: p.y }, first: { x: p.x, y: p.y }, cursor: null }; + _lineSel = { name, idx: null }; + e3dSync(); + return; + } const a = E3D.draw.start; - if (Math.hypot(p.x - a.x, p.y - a.y) < SNAP / GRID) { E3D.draw = null; e3dSync(); return; } // клик в ту же точку - E3D.draw = null; + if (Math.hypot(p.x - a.x, p.y - a.y) < SNAP / GRID) { _e3dDrawFinish(); return; } // повторный клик в последнюю точку + // Линия забора не проходит сквозь дом и террасу (правка 2026-09-26): концы + // уже держатся в 3 м от них, но отрезок между двумя разрешёнными точками мог + // пересечь дом насквозь. + const why = (name === 'fence' && typeof fenceSegBlocked === 'function') ? fenceSegBlocked(a, p) : null; + if (why) { + if (typeof dToast === 'function') { + dToast(why === 'cross' ? 'Забор не может проходить через дом или террасу' + : 'Ближе 3 м к дому и террасе забор не ставится'); + } + return; + } + // Дорожка тоже не проходит через дом и террасу (правка 2026-09-26), но + // подходить к ним вплотную может — она к ним и ведёт. + if (name === 'paths' && typeof pathSegBlocked === 'function' && pathSegBlocked(a, p)) { + if (typeof dToast === 'function') dToast('Дорожка не может проходить через дом или террасу'); + return; + } _e3dLineCommit(name, a, p); + const f = E3D.draw.first; + const closed = p.glue && f && Math.hypot(p.x - f.x, p.y - f.y) < 1e-9; + E3D.draw.start = { x: p.x, y: p.y }; // дальше тянемся от новой вершины + E3D.draw.cursor = null; + if (closed) E3D.draw = null; // замкнули контур — построение закончено _lineSel = { name, idx: null }; e3dSync(); if (typeof onParamChange === 'function') onParamChange(); if (typeof _dSyncSectionActions === 'function') _dSyncSectionActions(); } +// Закончить начатую линию: правая кнопка, Esc или клик в последнюю точку. +// Одиночная стартовая точка без отрезков ничего не оставляет. +function _e3dDrawFinish() { + if (!E3D.draw) return false; + E3D.draw = null; + e3dSync(); + return true; +} + // ── События ────────────────────────────────────────────────────────────── function _e3dCancelPress() { @@ -901,7 +996,13 @@ function e3dSetTouch(on) { c.touches.TWO = THREE.TOUCH.DOLLY_ROTATE; } +// Правая кнопка заканчивает рисуемую линию. Правой же кнопкой двигают вид +// (OrbitControls, панорама), поэтому линия заканчивается только КЛИКОМ — без +// протяжки; протяжка правой по-прежнему двигает вид. +let _e3dRightPress = null; + function _e3dOnDown(ev) { + if (ev.button === 2) { _e3dRightPress = { x: ev.clientX, y: ev.clientY }; return; } if (ev.button !== 0 || !E3D.sec || !threeState) return; // Второй палец — это жест камеры (два пальца крутят и масштабируют вид): // начатую протяжку бросаем, иначе объект уехал бы вместе с видом. @@ -935,6 +1036,13 @@ function _e3dOnMove(ev) { } function _e3dOnUp(ev) { + if (ev.button === 2) { + const r = _e3dRightPress; + _e3dRightPress = null; + if (r && E3D.draw && Math.abs(ev.clientX - r.x) <= E3D_MOVE_TOL + && Math.abs(ev.clientY - r.y) <= E3D_MOVE_TOL) _e3dDrawFinish(); + return; + } if (!E3D.press) return; const pr = E3D.press; E3D.press = null; @@ -949,14 +1057,18 @@ function _e3dOnUp(ev) { } if (pr.hit) { + // Во время рисования клик по точке линии — это вершина (соединение с + // точкой), а не выбор: построение продолжается. + if (E3D_KIND[E3D.sec] === 'line' && E3D.draw && pr.hit.idx !== 'gate' && pr.np) { + _e3dDrawClick(pr.np); + return; + } e3dSelect(pr.hit); // Грядка и мебель поворачиваются на 90° тем же кликом, которым выбираются // (как было на плане): отдельной ручки у них нет. if (pr.hit.kind === 'beds' || pr.hit.kind === 'point') { _e3dRotate(pr.hit); return; } - // В линиях клик по точке ещё и начинает отрезок от неё — так соседний - // отрезок приклеивается к существующей ломаной (ТЗ: «клик по существующей - // точке — склейка»). - if (E3D_KIND[E3D.sec] === 'line' && pr.hit.idx !== 'gate' && pr.np) _e3dDrawClick(pr.np); + // В линиях клик по точке только выделяет её (правка 2026-09-26) — дальше + // её можно тянуть или удалить; новую линию он не начинает. return; } // Клик по пустому месту: в линиях — рисуем, в остальных разделах — снимаем выбор. @@ -992,6 +1104,9 @@ function e3dAttach() { window.addEventListener('pointerup', _e3dOnUp); el.addEventListener('pointermove', _e3dOnHover); window.addEventListener('keydown', _e3dOnKey); + // Меню браузера по правой кнопке над видом не нужно: правая кнопка здесь + // заканчивает линию и двигает вид. + el.addEventListener('contextmenu', ev => { if (E3D.sec) ev.preventDefault(); }); E3D.bound = true; // Сцена могла родиться позже, чем включился мобильный режим — переносим на // неё настройку жестов (touches у OrbitControls живут в самой сцене). @@ -1005,8 +1120,7 @@ function _e3dOnKey(ev) { const t = ev.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; if (ev.key === 'Escape') { - if (!E3D.draw) return; - E3D.draw = null; ev.preventDefault(); e3dSync(); + if (_e3dDrawFinish()) ev.preventDefault(); return; } if (ev.key !== 'Delete' && ev.key !== 'Backspace') return; diff --git a/index.html b/index.html index a6b10d6..41644c9 100644 --- a/index.html +++ b/index.html @@ -4,9 +4,9 @@