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 @@ Конфигуратор дома — Десктоп - + - + @@ -222,9 +222,19 @@ -
Нажимая кнопку «Отправить» я даю согласие на обработку - персональных данных
+ +
+ +
@@ -250,8 +260,8 @@ - - + + - + - + - + - + - + diff --git a/nav-desktop.js b/nav-desktop.js index b24135a..9fcebe0 100644 --- a/nav-desktop.js +++ b/nav-desktop.js @@ -22,7 +22,9 @@ const D_SIDEBAR_ITEMS = [ { id: 'facade2', lbl: 'Отделка фасада 2', hasEditor: true }, { id: 'beds', lbl: 'Грядки', hasEditor: true }, { id: 'furniture', lbl: 'Садовая мебель', hasEditor: true }, - { id: 'pool_terrace', lbl: 'Терраса у бассейна', hasEditor: true }, + // Убран из интерфейса (правка 2026-09-26). Код раздела оставлен: снимки + // проектов с бассейном по-прежнему открываются и считаются. + { id: 'pool_terrace', lbl: 'Терраса у бассейна', hasEditor: true, hidden: true }, ]; // Пункты, реально попадающие в меню (и в замер ширины левой панели). const D_MENU_ITEMS = D_SIDEBAR_ITEMS.filter(i => !i.hidden); @@ -752,7 +754,7 @@ function _dInit3dSlot(slotId, step, after) { const D_TERRACE_H_PARAM = `
- Высота настила, см. + Высота террасы, см.
@@ -1330,14 +1332,13 @@ function dRailingEntry() { // открывает редактор заново, как при первом заходе (TODO п.3). Раньше редактор // оставался открытым и сразу подставлял стартовую разметку — «удалить всё» на // экране выглядело как «переставить заново». +// «Удалить всё» работает сразу, без окна «Подтвердите действие…» (правка +// 2026-09-26): цена ошибки невысока — раздел размечается заново за минуту, — а +// системное окно браузера пугало. function dResetSection(secId) { - const item = D_SIDEBAR_ITEMS.find(i => i.id === secId); - const label = item ? item.lbl : secId; - if (!window.confirm(`Удалить всё в разделе «${label}»?`)) return; - if (S.pts && S.pts[secId]) S.pts[secId] = []; if (RECT_SECTIONS[secId]) { secRects(secId).length = 0; setSecActiveIdx(secId, null); } - if (secId === 'steps') S.stepsList = [{ ...DEFAULT_STEPS_RECT }]; S.activeSteps = 0; + if (secId === 'steps') { S.stepsList = [{ ...DEFAULT_STEPS_RECT }]; S.activeSteps = 0; } if (secId === 'beds') { S.beds = []; S.activeBed = null; } if (secId === 'furniture') { S.furniture = []; S.activeFurniture = null; } if (secId === 'facade') S.wallZones = {}; @@ -1384,6 +1385,9 @@ function _dSelectItem(secId) { if (!S.sections.includes(secId)) S.sections.push(secId); _dSeedSection(secId); + // Ступени при создании сразу получают материал под доску террасы — той же фирмы + // и цвета. Если пользователь уже выбрал свой, подбор его не тронет. + if (secId === 'steps') _syncDefaultStepsProduct(); // Раздел считается настроенным, как только в нём есть объекты: подтверждать // («Дальше») больше нечего. if (_dSectionHasContent(secId)) dConfigured.add(secId); @@ -1450,8 +1454,8 @@ const D_SECTION_HINTS = { pool_terrace: 'Отдельно стоящая терраса: тяните за углы или за тело. «БАССЕЙН ▭» и «БАССЕЙН ○» ставят бассейн — в настиле на его месте будет вырез; повторное нажатие убирает.', steps: 'Лестницу двигают за середину, ширину меняют маркерами по краям. Разворачивается к террасе автоматически, количество ступеней считается от высоты.', beds: 'Грядку перетаскивайте мышью; клик по ней разворачивает на 90°.', - paths: 'Дорожка рисуется отрезками: клик — начало, второй клик — конец. Следующий отрезок — снова клик. Клик по уже поставленной точке склеивает отрезки, Esc отменяет начатый.', - fence: 'Забор рисуется отрезками: клик — начало, второй клик — конец. Клик по уже поставленной точке склеивает. Ближе 3 м к дому и террасе забор не ставится. «КАЛИТКА» ставит готовую калитку — маркер двигают по линии.', + paths: 'Дорожка рисуется кликами: каждый клик добавляет точку, линия тянется дальше. Закончить — правой кнопкой мыши, Esc или повторным кликом в последнюю точку. Клик по готовой точке выделяет её — её можно перетащить или удалить. Через дом и террасу дорожка не проходит.', + fence: 'Забор рисуется кликами: каждый клик добавляет точку, линия тянется дальше. Закончить — правой кнопкой мыши, Esc или повторным кликом в последнюю точку. Клик по готовой точке выделяет её. Через дом и террасу и ближе 3 м к ним забор не проходит. «КАЛИТКА» ставит готовую калитку — маркер двигают по линии.', railing: 'Ограждение строится по периметру террасы само и разрывается под лестницей. Нужен разрыв без лестницы — «ОБОЗНАЧИТЬ ВХОД», затем тяните маркеры разрыва по периметру.', furniture: 'Мебель появляется в сцене при выборе товара в каталоге. Перетаскивайте её мышью, клик разворачивает на 90°; на террасе она встаёт на настил.', facade: 'Кликайте по стенам дома, отмечая места под отделку. Повторный клик снимает выбор. Простенок и фронтон делятся по границам окна; пояс по линии карниза отделывается вместе с соседней стеной.', @@ -1463,6 +1467,9 @@ const D_SECTION_HINTS = { // смену текста в углу легко не заметить, а первый заход требует прочитать жесты. // Помним показанные разделы в рамках сессии. const _dHintShown = new Set(); +// Редакции подсказок разделов. Дорожки и забор: 2 — рисование непрерывное, до +// правой кнопки; дорожки: 3 — через дом и террасу не проходят (правки 2026-09-26). +const D_HINT_REV = { paths: 3, fence: 2 }; // Текст подсказки раздела. На телефоне жесты другие («касание» вместо «клик»), // поэтому мобильная раскладка подменяет формулировки через mHintText. @@ -1479,8 +1486,11 @@ function _dShowEditorHint(secId) { // На телефоне окна показываются очередью (mHintTake): при первом заходе их // может совпасть несколько, и они перекрывали бы друг друга. const item0 = D_SIDEBAR_ITEMS.find(i => i.id === secId); + // Номер редакции подсказки входит в ключ памяти: изменилась логика раздела — + // подсказку надо показать заново тем, кто видел старую. + const rev = D_HINT_REV[secId] ? '@' + D_HINT_REV[secId] : ''; if (typeof mHintTake === 'function' - && mHintTake('sec:' + secId, item0 ? item0.lbl : '', text)) return; + && mHintTake('sec:' + secId + rev, item0 ? item0.lbl : '', text)) return; const ov = document.getElementById('d-hint-overlay'); const body = document.getElementById('d-hint-text'); const title = document.getElementById('d-hint-title'); @@ -1674,34 +1684,73 @@ function _bedHeightFromProduct(sample) { return m ? parseInt(m[1], 10) / 1000 : null; } -// Товар ступеней «по умолчанию» подтягивается за доской террасы: если пользователь -// сам ступени не выбирал, берём товар из default_steps_id и красим ими 3D. Пометка -// auto:true отличает автоматический выбор от ручного — ручной не перетираем. +// Ступени по умолчанию — ТОЙ ЖЕ ФИРМЫ И ЦВЕТА, что доска террасы (правка +// 2026-09-26). Порядок выбора: +// 1) товар из default_steps_id террасы, если он той же фирмы и цвета — +// связка, которую ведёт бэкенд; +// 2) иначе — ступени той же фирмы и цвета из каталога раздела ступеней; +// 3) иначе — default_steps_id как есть (хоть та же линейка). +// Фирма — первое слово названия товара («TalverWood Стандарт 150×25»): отдельного +// поля производителя в API нет. Выбор пользователя не отключается: ручной +// выбор помечен без auto, и автоматический его не перетирает. let _defStepsBusy = null; -function _syncDefaultStepsProduct() { - const id = _defaultStepsProductId(); + +function _productBrand(p) { + const m = /^\s*(\S+)/.exec((p && p.name) || ''); + return m ? m[1].toLowerCase() : ''; +} +function _productColor(p) { + return String((p && (p.colorName || p.color)) || '').trim().toLowerCase(); +} + +async function _syncDefaultStepsProduct() { + const t = S.elementMat && S.elementMat.terrace; const cur = S.elementMat && S.elementMat.steps; if (cur && !cur.auto) return; // ступени выбраны вручную - if (!id) { if (cur && cur.auto) { delete S.elementMat.steps; _rebuild3d(); } return; } - if (cur && cur.auto && cur.productId === id) return; // уже стоит нужный - if (_defStepsBusy === id) return; - const rm = _getRM(); - if (!rm) return; - _defStepsBusy = id; - rm.getProductById(id).then(async product => { - _defStepsBusy = null; - if (!product) return; + if (!t || !t.productId) { + if (cur && cur.auto) { delete S.elementMat.steps; delete S.estimate.steps; _rebuild3d(); } + return; + } + if (cur && cur.auto && cur.forTerrace === t.productId) return; // уже подобраны под эту доску + if (_defStepsBusy === t.productId) return; + _defStepsBusy = t.productId; + try { + const brand = _productBrand(t), color = _productColor(t); + const same = p => !!p && _productBrand(p) === brand && (!color || _productColor(p) === color); + const defId = _defaultStepsProductId(); + const def = defId ? await _loadProduct(defId) : null; + let product = same(def) ? def : null; + if (!product) { + const list = await _loadCatalogRaw(CONSTRUCTION_TO_SECTION.steps); + const hit = (list || []).find(same); + if (hit) product = await _loadProduct(hit.id); + } + if (!product) product = def; + if (!product) { console.info('[steps] ступеней той же фирмы и цвета не нашлось'); return; } const now = S.elementMat && S.elementMat.steps; if (now && !now.auto) return; // пока грузили, выбрали вручную - try { await product.loadTextures(); } catch (_) {} - const meta = { productId: product.id, name: product.name || '', - colorName: product.color || '', properties: product.properties || null, - previewText: product.previewText || '', modelUrl: product.modelUrl || '', - auto: true }; - S.elementMat.steps = product.textures ? { textures: product.textures, ...meta } : meta; - console.info('[steps] ступень по умолчанию из доски террасы:', product.name || id); - _rebuild3d(); - }).catch(() => { _defStepsBusy = null; }); + const tNow = S.elementMat && S.elementMat.terrace; + if (!tNow || tNow.productId !== t.productId) return; // доску уже сменили + _applySampleTo('steps', _sampleFromProduct(product)); + S.elementMat.steps.auto = true; + S.elementMat.steps.forTerrace = t.productId; + console.info('[steps] ступени по умолчанию под доску террасы:', product.name || product.id, + same(product) ? '(та же фирма и цвет)' : '(по связке default_steps_id)'); + } finally { + _defStepsBusy = null; + } +} + +// Каталог раздела БЕЗ пользовательских фильтров: для подбора ступеней под террасу +// фильтры активного раздела (например «Пустотелая» у террасы) неприменимы — они +// отсекли бы полнотелые ступени целиком. Пока раздел грузится, ждём. +async function _loadCatalogRaw(sectionId) { + for (let i = 0; i < 20; i++) { + const list = await _ensureCatalogSection(sectionId, { raw: true }); + if (list !== undefined) return list; + await new Promise(r => setTimeout(r, 300)); + } + return null; } function _rebuild3d() { if (typeof buildScene3d === 'function') buildScene3d(); } @@ -2181,11 +2230,14 @@ function _activeSectionId() { // [] — раздел реально пуст (fallback на заглушки, не перезапрашиваем); // null — ошибка/недоступно (перезапросим, но не больше CATALOG_MAX_TRIES раз); // undefined — ещё не грузили. -async function _ensureCatalogSection(sectionId) { +async function _ensureCatalogSection(sectionId, opts) { // Ключ кэша учитывает выбранные ценовые категории: их отбирает сервер, и для // разных наборов выдачи разные. Счётчик ошибок остаётся на разделе — не // отвечает именно он, а не отдельный фильтр. - const key = _catKey(sectionId); + // opts.raw — выдача без пользовательских фильтров (фильтры берутся у АКТИВНОГО + // раздела, а зовут нас и для чужого: подбор ступеней под террасу). + const raw = !!(opts && opts.raw); + const key = raw ? String(sectionId) : _catKey(sectionId); if (Array.isArray(_catalogCache[key])) return _catalogCache[key]; if (_catalogLoading[key]) return undefined; // Сервер не отвечает вовсе — запросы прекращены совсем (см. _catalogNoteFail). @@ -2214,8 +2266,8 @@ async function _ensureCatalogSection(sectionId) { // Ценовая категория — предикат по характеристике товара (ТЗ п. 4): вилки цен // на клиенте больше не считаются. Несколько выбранных категорий идут одним // предикатом IN. - const cats = _selectedPriceCats(); - const cores = _selectedCoreTypes(); + const cats = raw ? [] : _selectedPriceCats(); + const cores = raw ? [] : _selectedCoreTypes(); const preds = []; if (typeof PropertyPath !== 'undefined' && typeof PropertyOp !== 'undefined') { const pred = (path, list) => ({ @@ -3416,19 +3468,29 @@ function dShowSummary() { // ── Заявка: имя, почта, согласие ────────────────────────────────────────── // «Отправить заявку» в смете открывает это окно; по «Отправить» на сервер уходит -// имя, почта и ОПИСАНИЕ проекта (project-io.js). Смету бэкенд считает сам, в -// запрос она не входит. Ключ проекта возвращается в ответе — по нему приходит -// ссылка в письме. +// имя, почта, ОПИСАНИЕ проекта и данные для расчёта (project-io.js): по ним +// бэкенд сам собирает смету в PDF к письму — посчитанной сметы в запросе нет. +// Ключ проекта возвращается в ответе — по нему приходит ссылка в письме. function dOpenRequest() { const ov = document.getElementById('d-req-overlay'); if (!ov) return; const err = document.getElementById('d-req-err'); if (err) err.textContent = ''; + dSyncRequestConsent(); ov.classList.add('active'); const name = document.getElementById('d-req-name'); if (name) setTimeout(() => name.focus(), 50); } +// Без галочки согласия «Отправить» заблокирована (правка 2026-09-26). Галочка +// между открытиями окна не сбрасывается: однажды данное согласие переспрашивать +// незачем, а снять его пользователь может сам. +function dSyncRequestConsent() { + const box = document.getElementById('d-req-consent'); + const btn = document.getElementById('d-req-send'); + if (btn) btn.disabled = !(box && box.checked); +} + function dCloseRequest() { const ov = document.getElementById('d-req-overlay'); if (ov) ov.classList.remove('active'); @@ -3453,14 +3515,24 @@ async function dSendRequest() { if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { say('Проверьте адрес почты.'); if (mailEl) mailEl.focus(); return; } + const consent = document.getElementById('d-req-consent'); + if (consent && !consent.checked) { say('Отметьте согласие на обработку данных.'); return; } if (typeof saveProjectToServer !== 'function') { say('Сервис недоступен.'); return; } say(''); if (btn) { btn.disabled = true; btn.textContent = 'Отправляем…'; } + const dump = document.getElementById('d-req-dump'); + if (dump) dump.hidden = true; let res = null; try { res = await saveProjectToServer(name, email); } - catch (e) { console.warn('[project] save failed', e); res = { error: 'Не удалось отправить заявку.' }; } - if (btn) { btn.disabled = false; btn.textContent = 'Отправить'; } - if (!res || res.error) { say((res && res.error) || 'Не удалось отправить заявку.'); return; } + catch (e) { console.warn('[project] save failed', e); res = { error: 'Не удалось отправить заявку.', canDump: true }; } + if (btn) btn.textContent = 'Отправить'; + dSyncRequestConsent(); // доступность кнопки — снова по галочке + if (!res || res.error) { + say((res && res.error) || 'Не удалось отправить заявку.'); + // Отказ разбирает бэкенд — даём выгрузить то, что реально ушло. + if (dump && typeof pioDumpRequest === 'function') dump.hidden = false; + return; + } dCloseRequest(); dCloseSummary(); const done = document.getElementById('d-req-done'); diff --git a/nav-mobile.js b/nav-mobile.js index 89ad63c..49bd6b7 100644 --- a/nav-mobile.js +++ b/nav-mobile.js @@ -236,8 +236,8 @@ const M_SECTION_HINTS = { pool_terrace: 'Отдельно стоящая терраса: тяните за углы или за тело. «БАССЕЙН ▭» и «БАССЕЙН ○» ставят бассейн — в настиле на его месте будет вырез; повторное касание убирает.', steps: 'Лестницу двигают за середину, ширину меняют маркерами по краям. Разворачивается к террасе автоматически, количество ступеней считается от высоты.', beds: 'Грядку перетаскивайте пальцем; касание по ней разворачивает её на 90°.', - paths: 'Дорожка рисуется отрезками: касание — начало, второе — конец. Следующий отрезок — снова касание. Касание по уже поставленной точке склеивает отрезки.', - fence: 'Забор рисуется отрезками: касание — начало, второе — конец. Касание по уже поставленной точке склеивает. Ближе 3 м к дому и террасе забор не ставится. «КАЛИТКА» ставит готовую калитку — маркер двигают по линии.', + paths: 'Дорожка рисуется касаниями: каждое касание добавляет точку, линия тянется дальше. Закончить — повторным касанием последней точки. Касание готовой точки выделяет её — её можно перетащить или удалить. Через дом и террасу дорожка не проходит.', + fence: 'Забор рисуется касаниями: каждое касание добавляет точку, линия тянется дальше. Закончить — повторным касанием последней точки. Касание готовой точки выделяет её. Через дом и террасу и ближе 3 м к ним забор не проходит. «КАЛИТКА» ставит готовую калитку — маркер двигают по линии.', railing: 'Ограждение строится по периметру террасы само и разрывается под лестницей. Нужен разрыв без лестницы — «ОБОЗНАЧИТЬ ВХОД», затем тяните маркеры разрыва по периметру.', furniture: 'Мебель появляется в сцене при выборе товара в каталоге. Перетаскивайте её пальцем, касание разворачивает на 90°; на террасе она встаёт на настил.', facade: 'Касайтесь стен дома, отмечая места под отделку. Повторное касание снимает выбор. Простенок и фронтон делятся по границам окна; пояс по линии карниза отделывается вместе с соседней стеной.', diff --git a/project-io.js b/project-io.js index 244cc2c..6f8406e 100644 --- a/project-io.js +++ b/project-io.js @@ -90,7 +90,13 @@ function buildProjectSnapshot() { el[sec].pool = { kind: S.pool.kind || 'rect', ..._pioRect(S.pool) }; } } - const stairs = (typeof stepsAll === 'function') ? stepsAll() : []; + // Ступени и ограждение пишем, только если раздел ВКЛЮЧЁН в проект. У ступеней + // в состоянии всегда лежит заготовка (DEFAULT_STEPS_RECT), у ограждения могут + // остаться старые разрывы — без этой проверки они уходили в снимок, и при + // открытии проекта по ссылке появлялись ступени, которых никто не строил + // (баг 2026-09-26). Рисует сцена их тоже только для включённого раздела. + const inProject = sec => (S.sections || []).includes(sec); + const stairs = (inProject('steps') && typeof stepsAll === 'function') ? stepsAll() : []; if (stairs.length) { el.steps = { product: _pioProduct('steps'), @@ -107,7 +113,7 @@ function buildProjectSnapshot() { points: _pioPts(S.pts.fence) }; if (S.fenceGate) el.fence.gate = { x: _pioMm(S.fenceGate.x), y: _pioMm(S.fenceGate.y) }; } - if ((S.railingEntries || []).length || (S.sections || []).includes('railing')) { + if (inProject('railing')) { el.railing = { product: _pioProduct('railing'), postWidthMm: S.railPostW || null, entries: (S.railingEntries || []).map(e => (e ? { t0: e.t0, t1: e.t1 } : null)) }; @@ -287,12 +293,90 @@ function _pioCalculation() { return { type, ...req.payload }; } +// Текст отказа для окна заявки: сервер объясняет причину, и молчать о ней +// нельзя — иначе «попробуйте ещё раз» уводит от настоящей проблемы. +function _pioSaveErrorText(rm) { + const e = rm && rm.lastSaveError; + if (!e) return 'Не удалось отправить заявку. Попробуйте ещё раз.'; + if (e.status === 429) return 'Слишком много заявок подряд. Попробуйте через несколько минут.'; + if (e.status === 413) return 'Проект слишком большой для отправки. Напишите нам, разберёмся.'; + if (e.message) return 'Сервер не принял заявку: ' + e.message; + if (!e.status) return 'Сервис недоступен. Проверьте связь и попробуйте ещё раз.'; + return `Не удалось отправить заявку (ошибка ${e.status}). Попробуйте ещё раз.`; +} + +// Тело запроса заявки целиком — им же отправляем и его же кладём в файл. +function pioRequestBody(name, email) { + return { name: name || '', email: email || '', + data: buildProjectSnapshot(), calculation: _pioCalculation() }; +} + +// Последняя попытка отправки: тело и ответ сервера. Нужна выгрузке в файл — +// в ней должно лежать ровно то, что ушло, а не собранное заново. +let _pioLastAttempt = null; + +// Запрос в JSON-файл: с сайта заявка не уходит, а из репозитория уходит, и +// разбираться в этом бэкенду проще по тому, что реально отправлялось. +// В файле три части: request — тело как есть (его можно послать серверу +// повторно), response — ответ (код 0 значит, что ответа не было вовсе: сеть, +// CORS или заблокированный запрос), meta — откуда и куда отправляли. +function pioDumpRequest(name, email) { + const a = _pioLastAttempt || { body: pioRequestBody(name, email), response: null, + at: new Date().toISOString() }; + const domain = (typeof RESOURCE_API_DOMAIN !== 'undefined') ? RESOURCE_API_DOMAIN : null; + const dump = { + request: a.body, + response: a.response, + meta: { + at: a.at, + page: (typeof location !== 'undefined') ? location.href : '', + apiDomain: (domain === '') ? '(тот же домен, локальный прокси)' + : (domain || '(не задан)'), + endpoint: (domain || '') + '/api/v1/create_project/', + formatVersion: PROJECT_FORMAT_VERSION, + userAgent: (typeof navigator !== 'undefined') ? navigator.userAgent : '', + }, + }; + try { + const stamp = dump.meta.at.replace(/[:T]/g, '-').slice(0, 16); + const blob = new Blob([JSON.stringify(dump, null, 1)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a2 = document.createElement('a'); + a2.href = url; + a2.download = `shpuk-request-${stamp}.json`; + document.body.appendChild(a2); + a2.click(); + a2.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (e) { console.warn('[project] файл не сохранился', e); } + console.info('[project] запрос заявки:', dump); + return dump; +} + async function saveProjectToServer(name, email) { const rm = (typeof _getRM === 'function') ? _getRM() : null; if (!rm || typeof rm.saveProject !== 'function') return { error: 'Сервис недоступен.' }; - const data = buildProjectSnapshot(); - const res = await rm.saveProject(name, email, data, _pioCalculation()); - if (!res) return { error: 'Не удалось отправить заявку. Попробуйте ещё раз.' }; + const body = pioRequestBody(name, email); + const data = body.data; + _pioLastAttempt = { body, response: null, at: new Date().toISOString() }; + let res = await rm.saveProject(name, email, data, body.calculation); + _pioLastAttempt.response = res + ? { status: 'ok', body: res } + : (rm.lastSaveError || { status: 0, message: '', body: '' }); + // Смета к письму — приятное дополнение, а ссылка на проект — суть заявки. + // Если сервер забраковал именно данные для расчёта (он проверяет их теми же + // правилами, что и сам расчёт), повторяем без них: клиент получит письмо со + // ссылкой, а причина останется в консоли. + const err = rm.lastSaveError; + if (!res && err && err.status === 400 && /calculation/i.test(err.message || err.body || '')) { + console.warn('[project] смета не принята сервером, отправляем заявку без неё:', err.message); + res = await rm.saveProject(name, email, data, null); + _pioLastAttempt.retriedWithoutCalculation = true; + _pioLastAttempt.response = res + ? { status: 'ok', body: res, note: 'принято со второй попытки, без calculation' } + : (rm.lastSaveError || _pioLastAttempt.response); + } + if (!res) return { error: _pioSaveErrorText(rm), canDump: true }; return { saveId: res.saveId || res.save_id || res.id || res.key || null, raw: res }; } diff --git a/state.js b/state.js index 2faf071..f130ecb 100644 --- a/state.js +++ b/state.js @@ -494,12 +494,22 @@ function facadeOtherSec(secId) { return (secId === 'facade2') ? 'facade' : 'facade2'; } +// Выбор фильтров, с которым раздел открывается впервые. У террасы по умолчанию +// включена «Пустотелая» (правка 2026-09-26): ходовая доска для настила, а +// полнотелую пользователь включит сам. Снять выбор можно — это только старт. +const CAT_FILTER_DEFAULTS = { + terrace: { core: ['hollow'] }, +}; + function catFilter(secId) { const key = secId || '_'; let f = S.catFilters[key]; // core — сечение доски (полнотелая / пустотелая), характеристика core_type: // отбирает бэкенд, как и ценовую категорию. - if (!f) { f = S.catFilters[key] = { colors: new Set(), prices: new Set(), core: new Set() }; } + if (!f) { + const d = CAT_FILTER_DEFAULTS[key] || {}; + f = S.catFilters[key] = { colors: new Set(), prices: new Set(), core: new Set(d.core || []) }; + } if (!f.core) f.core = new Set(); return f; } diff --git a/styles-desktop.css b/styles-desktop.css index 8eae896..2325b1d 100644 --- a/styles-desktop.css +++ b/styles-desktop.css @@ -883,8 +883,27 @@ button, input, select, textarea { font-family: inherit; } } .d-req-input:focus { border-color: var(--accent); } .d-req-note { font-size: 13px; font-weight: 400; color: #888; line-height: 1.35; } +/* Галочка согласия: флажок акцентного цвета, текст в две строки рядом с ним, + ссылка на политику — подчёркнутая, в цвет акцента. */ +.d-req-consent { + display: flex; align-items: flex-start; gap: 10px; width: 100%; cursor: pointer; + font-size: 13px; font-weight: 400; color: #666; line-height: 1.35; +} +.d-req-consent input { + flex: none; width: 18px; height: 18px; margin: 1px 0 0; cursor: pointer; + accent-color: var(--accent); +} +.d-req-consent a { color: var(--accent); text-decoration: underline; } .d-req-err { font-size: 14px; color: #c0392b; min-height: 18px; } .d-req-err:empty { min-height: 0; } +/* Ссылка-кнопка под ошибкой: обычный путь её не показывает (hidden снимается + только после отказа сервера), поэтому оформлена скромно. */ +.d-req-dump { + align-self: flex-start; background: none; border: none; padding: 0; + font: inherit; font-size: 13px; color: var(--accent, #e8730a); + text-decoration: underline; cursor: pointer; +} +.d-req-dump[hidden] { display: none; } .d-req-actions { display: flex; gap: 8px; width: 100%; } .d-req-actions .d-btn { flex: 1; height: 44px; font-size: 16px; cursor: pointer; } diff --git a/styles-mobile.css b/styles-mobile.css index 07a96eb..0a06f4d 100644 --- a/styles-mobile.css +++ b/styles-mobile.css @@ -133,6 +133,9 @@ body.m-mode .d-hint-text { font-size: 15px; } body.m-mode .d-hint-box .d-btn { min-width: 120px; height: 44px; font-size: 16px; } body.m-mode .d-req-box { width: 100%; max-width: calc(100vw - 32px); } body.m-mode .d-req-input { height: 44px; font-size: 16px; } +/* Флажок под палец: 18 px мыши на телефоне мимо не промахнёшься только случайно. */ +body.m-mode .d-req-consent { font-size: 14px; gap: 12px; } +body.m-mode .d-req-consent input { width: 24px; height: 24px; } body.m-mode .d-summary-box { width: 100%; max-width: calc(100vw - 24px); max-height: 86vh; } body.m-mode .d-summary-header { padding: 14px 16px; } body.m-mode .d-summary-title { font-size: 19px; } diff --git a/viewer3d-core.js b/viewer3d-core.js index c783055..b915c46 100644 --- a/viewer3d-core.js +++ b/viewer3d-core.js @@ -1641,21 +1641,100 @@ function _collectFacadeSegments(root) { if (pillars.length && segs.length) { root.updateMatrixWorld(true); const segBoxes = segs.map(s => ({ id: s.userData.segId, box: new THREE.Box3().setFromObject(s) })); + // Стена соседа идёт вдоль той оси, по которой он длиннее: стена вдоль X лежит + // в плоскости ±Z-граней столба, вдоль Z — в плоскости ±X-граней. Фронтон — + // плоскость, у него один горизонтальный размер нулевой, правило то же. + const runsAlongX = box => (box.max.x - box.min.x) >= (box.max.z - box.min.z); + const followBoxes = pillars.filter(p => p.userData.facadeFollow) + .map(p => ({ obj: p, box: new THREE.Box3().setFromObject(p) })); + const c = new THREE.Vector3(), cs = new THREE.Vector3(); for (const p of pillars) { const follow = !!p.userData.facadeFollow; const pb = new THREE.Box3().setFromObject(p); - const adj = new Set(); + pb.getCenter(c); + const adj = new Set(), alongX = new Set(), alongZ = new Set(); for (const sb of segBoxes) { const hit = follow ? _facadeStackedOn(pb, sb.box) : _facadeSideBy(pb, sb.box); - if (hit) adj.add(sb.id); + if (!hit) continue; + adj.add(sb.id); + // Сосед СБОКУ: сторону даёт смещение центров — узкий простенок у угла + // может оказаться уже толщины стены, и правило «по длине» его перепутало бы. + sb.box.getCenter(cs); + (Math.abs(cs.x - c.x) >= Math.abs(cs.z - c.z) ? alongX : alongZ).add(sb.id); } p.userData._adjIds = [...adj]; + p.userData._adjAlongX = [...alongX]; + p.userData._adjAlongZ = [...alongZ]; + p.userData._followAlongX = []; + p.userData._followAlongZ = []; + if (follow) continue; + // Угловой столб на уровне карниза мансарды (баг 2026-09-26): сбоку у него + // только пояс карниза — он сам не сегмент, а над и под столбом фронтон и + // простенок лишь касаются его торцами. Соседей по правилу выше нет, и + // столб не красился никогда. Поэтому грань в плоскости пояса следует за + // поясом, а грань в плоскости фронтона — за фронтоном над столбом или за + // простенком под ним. + for (const fb of followBoxes) { + if (!_facadeSideBy(pb, fb.box)) continue; + (runsAlongX(fb.box) ? p.userData._followAlongX : p.userData._followAlongZ).push(fb.obj); + } + if (!adj.size) { + for (const sb of segBoxes) { + if (!_facadeStackedOn(pb, sb.box)) continue; + (runsAlongX(sb.box) ? p.userData._adjAlongX : p.userData._adjAlongZ).push(sb.id); + p.userData._adjIds.push(sb.id); + } + } } } else { - for (const p of pillars) p.userData._adjIds = []; + for (const p of pillars) { + p.userData._adjIds = []; p.userData._adjAlongX = []; p.userData._adjAlongZ = []; + p.userData._followAlongX = []; p.userData._followAlongZ = []; + } } } +// Угловой столб — один меш на оба фасада угла, и красился он целиком: отделка +// одной стены выходила на грань соседней (баг 2026-09-26). Геометрию столба +// один раз перекладываем по группам граней: 0 — грани, смотрящие вдоль ±X, +// 1 — вдоль ±Z, 2 — прочие (верх и низ). Дальше каждой группе свой материал. +function _facadeFaceGroups(mesh) { + if (mesh.userData._faceGroups) return; + mesh.updateMatrixWorld(true); + const src = mesh.geometry.index ? mesh.geometry.toNonIndexed() : mesh.geometry.clone(); + const pos = src.attributes.position; + const nm = new THREE.Matrix3().getNormalMatrix(mesh.matrixWorld); + const a = new THREE.Vector3(), b = new THREE.Vector3(), cc = new THREE.Vector3(); + const buckets = [[], [], []]; + for (let t = 0; t < pos.count; t += 3) { + a.fromBufferAttribute(pos, t); b.fromBufferAttribute(pos, t + 1); cc.fromBufferAttribute(pos, t + 2); + const n = new THREE.Vector3().subVectors(cc, b).cross(new THREE.Vector3().subVectors(a, b)); + n.applyMatrix3(nm); + const ax = Math.abs(n.x), ay = Math.abs(n.y), az = Math.abs(n.z); + buckets[(ax >= az && ax >= ay) ? 0 : (az >= ax && az >= ay) ? 1 : 2].push(t); + } + // Пересобираем атрибуты в порядке групп, чтобы каждая группа шла подряд. + const order = [...buckets[0], ...buckets[1], ...buckets[2]]; + const geo = new THREE.BufferGeometry(); + for (const name of Object.keys(src.attributes)) { + const attr = src.attributes[name], sz = attr.itemSize; + const out = new attr.array.constructor(order.length * 3 * sz); + order.forEach((t, k) => { + for (let v = 0; v < 3; v++) for (let i = 0; i < sz; i++) { + out[(k * 3 + v) * sz + i] = attr.array[(t + v) * sz + i]; + } + }); + geo.setAttribute(name, new THREE.BufferAttribute(out, sz, attr.normalized)); + } + let start = 0; + buckets.forEach((bk, gi) => { if (bk.length) geo.addGroup(start * 3, bk.length * 3, gi); start += bk.length; }); + geo.computeBoundingBox(); + geo.computeBoundingSphere(); + src.dispose(); + mesh.geometry = geo; + mesh.userData._faceGroups = true; +} + // Материал панелей отделки из S.elementMat[secId]: PBR-текстуры товара каталога // (раздел фасадных панелей, walls-тег) или однотонный цвет. null — не выбран. // Отделок две («Отделка фасада 1» и «2»), у каждой свой материал и свой набор @@ -1717,12 +1796,40 @@ function _applyFacadeSelection() { // _facadeSec читает facadeSelectedAreaM2 (площадь столба идёт в смету своего // раздела). Прежнее правило «пустой выбор = весь фасад» с двумя отделками не // работает: вторая залила бы собой весь дом. - for (const p of (threeState.facadePillars || [])) { + // Угловой столб красится ПО ГРАНЯМ (правка 2026-09-26): грань в плоскости + // стены берёт отделку этой стены, а соседняя грань остаётся родной, если её + // стена не выбрана. Пояс карниза (facadeFollow) — полоса одной стены, он + // красится целиком, как раньше. + // Сначала пояса карниза: по ним потом красятся угловые столбы на уровне карниза. + const pillars = threeState.facadePillars || []; + for (const p of pillars) { + if (!p.userData.facadeFollow) continue; const sec = (p.userData._adjIds || []).map(secOf).find(Boolean) || null; p.userData._facadeSec = sec; p.userData._facadeOn = !!sec; paint(p, sec ? mats[sec] : null); } + const bandSec = list => (list || []).map(o => o.userData._facadeSec).find(Boolean) || null; + for (const p of pillars) { + if (p.userData.facadeFollow) continue; + const secZf = (p.userData._adjAlongX || []).map(secOf).find(Boolean) + || bandSec(p.userData._followAlongX) || null; // грани ±Z + const secXf = (p.userData._adjAlongZ || []).map(secOf).find(Boolean) + || bandSec(p.userData._followAlongZ) || null; // грани ±X + p.userData._facadeSecX = secXf; + p.userData._facadeSecZ = secZf; + p.userData._facadeSec = secXf || secZf; + p.userData._facadeOn = !!(secXf || secZf); + p.traverse(o => { + if (!o.isMesh || !o.material) return; + if (!o.userData._baseMat) o.userData._baseMat = o.material; + const base = o.userData._baseMat; + if (!secXf && !secZf) { o.material = base; return; } + _facadeFaceGroups(o); + if (!o.userData._facadeUV) { _applyWorldBoxUV(o, HOUSE_WALL_TILE); o.userData._facadeUV = true; } + o.material = [secXf ? mats[secXf] : base, secZf ? mats[secZf] : base, base]; + }); + } } // Площадь элементов под отделку (м²) для сметы; пустой выбор = весь фасад. @@ -1740,8 +1847,13 @@ function facadeSelectedAreaM2(secId) { let a = list.reduce((s, o) => s + (o.userData.segArea !== undefined ? o.userData.segArea : (o.userData.segW || 0) * (o.userData.segH || 0)), 0); + // Угловой столб: у него две наружные грани (segW — их общая ширина), и каждая + // идёт в смету СВОЕЙ отделки — той, что выбрана на стене в её плоскости. for (const p of ((threeState && threeState.facadePillars) || [])) { - if (p.userData._facadeSec === sec) a += (p.userData.segW || 0) * (p.userData.segH || 0); + const face = (p.userData.segW || 0) * (p.userData.segH || 0); + if (p.userData.facadeFollow) { if (p.userData._facadeSec === sec) a += face; continue; } + if (p.userData._facadeSecX === sec) a += face / 2; + if (p.userData._facadeSecZ === sec) a += face / 2; } return a; }