diff --git a/backend/app/schemas/concept.py b/backend/app/schemas/concept.py
index c0c4cde8..c4778c20 100644
--- a/backend/app/schemas/concept.py
+++ b/backend/app/schemas/concept.py
@@ -20,6 +20,18 @@ class BuildingProgramItem(BaseModel):
floors: int = Field(..., ge=1, le=40, description="Floors for this house-type group")
# Сколько секций этого типа разместить.
count: int = Field(..., ge=1, le=50, description="Number of sections to place")
+ # ADDITIVE (Stage 3c): опциональное ручное пятно секции (ширина × глубина, м).
+ # None → габариты берутся из каталога по ``section_type`` (byte-for-byte
+ # backward-compat). Заданы → place_program строит footprint из них вместо
+ # каталожных (пользователь «вписал пятно»). Оба должны быть заданы вместе;
+ # частичное задание (только w / только d) трактуется как «не задано» на слое
+ # размещения. Границы [4, 120] м — вменяемый габарит секции/корпуса.
+ footprint_w_m: float | None = Field(
+ None, ge=4, le=120, description="Optional manual section footprint width, m"
+ )
+ footprint_d_m: float | None = Field(
+ None, ge=4, le=120, description="Optional manual section footprint depth, m"
+ )
class HouseTypeCatalogItem(BaseModel):
diff --git a/backend/app/services/generative/placement.py b/backend/app/services/generative/placement.py
index 5c17de4e..0c4d2ce1 100644
--- a/backend/app/services/generative/placement.py
+++ b/backend/app/services/generative/placement.py
@@ -273,13 +273,18 @@ def place_program(
for item in program:
house = catalog.get_house_type(item.section_type)
requested += item.count
+ # Ручное пятно (Stage 3c): если пользователь задал ОБА габарита — кладём
+ # его вместо каталожного («вписать пятно»). Частичное задание игнорируем
+ # (нужны и ширина, и глубина), падая обратно на каталог.
+ if item.footprint_w_m is not None and item.footprint_d_m is not None:
+ fp_w, fp_d = item.footprint_w_m, item.footprint_d_m
+ else:
+ fp_w, fp_d = house.footprint_w_m, house.footprint_d_m
placed_for_item = 0
for cell in parcel.grid:
if placed_for_item >= item.count:
break
- footprint = _centered_footprint(
- cell.cx, cell.cy, house.footprint_w_m, house.footprint_d_m
- )
+ footprint = _centered_footprint(cell.cx, cell.cy, fp_w, fp_d)
if placer.try_place(footprint, buildable, half_gap):
placed_sections.append(
_PlacedSection(
diff --git a/backend/tests/services/generative/test_placement.py b/backend/tests/services/generative/test_placement.py
index 558b556c..fb83db94 100644
--- a/backend/tests/services/generative/test_placement.py
+++ b/backend/tests/services/generative/test_placement.py
@@ -9,8 +9,8 @@ from __future__ import annotations
from shapely.geometry import shape
-from app.schemas.concept import ConceptInput
-from app.services.generative import geometry, placement
+from app.schemas.concept import BuildingProgramItem, ConceptInput
+from app.services.generative import catalog, geometry, placement
_PARCEL_COORDS = [
[60.60, 56.830],
@@ -67,10 +67,7 @@ def test_max_area_denser_than_max_insolation() -> None:
parcel = geometry.parse_parcel(payload)
variants = {v.strategy: v for v in placement.place_all_strategies(parcel, payload)}
# Максимум площади даёт большее пятно/жилую, чем максимум инсоляции.
- assert (
- variants["max_area"].teap.built_area_sqm
- > variants["max_insolation"].teap.built_area_sqm
- )
+ assert variants["max_area"].teap.built_area_sqm > variants["max_insolation"].teap.built_area_sqm
assert (
variants["max_area"].teap.residential_area_sqm
> variants["max_insolation"].teap.residential_area_sqm
@@ -104,6 +101,45 @@ def test_buildings_geojson_features_are_valid_polygons() -> None:
assert feat["properties"]["floors"] >= 1
+def test_program_footprint_override_changes_section_area() -> None:
+ # Stage 3c: ручное пятно (footprint_w_m/_d_m) кладётся вместо каталожного.
+ parcel = geometry.parse_parcel(_payload())
+ section_type = next(iter(catalog.available_section_types()))
+ house = catalog.get_house_type(section_type)
+
+ override = BuildingProgramItem(
+ section_type=section_type,
+ floors=10,
+ count=2,
+ footprint_w_m=10.0,
+ footprint_d_m=10.0,
+ )
+ placed = placement.place_program(parcel, [override])
+ assert placed.placed_count >= 1
+ # Каждая размещённая секция несёт пятно override (10×10 = 100 м²), не каталог.
+ for sec in placed.sections:
+ assert abs(sec.footprint.area - 100.0) < 1.0
+
+ # Без override — пятно из каталога (контроль: байт-в-байт прежнее поведение).
+ base = BuildingProgramItem(section_type=section_type, floors=10, count=2)
+ placed_base = placement.place_program(parcel, [base])
+ catalog_area = house.footprint_w_m * house.footprint_d_m
+ for sec in placed_base.sections:
+ assert abs(sec.footprint.area - catalog_area) < 1.0
+
+
+def test_program_partial_footprint_falls_back_to_catalog() -> None:
+ # Только ширина задана (без глубины) → трактуем как «не задано», берём каталог.
+ parcel = geometry.parse_parcel(_payload())
+ section_type = next(iter(catalog.available_section_types()))
+ house = catalog.get_house_type(section_type)
+ item = BuildingProgramItem(section_type=section_type, floors=10, count=1, footprint_w_m=10.0)
+ placed = placement.place_program(parcel, [item])
+ catalog_area = house.footprint_w_m * house.footprint_d_m
+ for sec in placed.sections:
+ assert abs(sec.footprint.area - catalog_area) < 1.0
+
+
def test_placement_deterministic() -> None:
payload = _payload()
p1 = geometry.parse_parcel(payload)
diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py
index 87ef06a1..43ef1aae 100644
--- a/backend/tests/services/test_quarter_dump_lookup.py
+++ b/backend/tests/services/test_quarter_dump_lookup.py
@@ -685,12 +685,15 @@ def test_nspd_path_used_when_zouit_count_nonzero() -> None:
Проверяем source='nspd-quarter-dump' (не cad_zouit).
"""
# Строка дампа: все поля через MagicMock
- from datetime import UTC, datetime
+ from datetime import UTC, datetime, timedelta
dump_row = MagicMock()
dump_row.__getitem__ = lambda self, i: [
"66:41:0603016", # quarter_cad
- datetime(2026, 1, 1, tzinfo=UTC), # fetched_at_utc (свежий)
+ # Свежесть ОТНОСИТЕЛЬНО now: был хардкод datetime(2026, 1, 1) → date-bomb
+ # (с 2026-06-30 перевалил _DUMP_MAX_AGE_DAYS=180 → stale-ветка → _trigger_harvest
+ # → _empty_unavailable → overlaps=[] → assert 0 == 1). now − 1 день всегда свеж.
+ datetime.now(UTC) - timedelta(days=1), # fetched_at_utc (свежий)
100, # total_features
None, # harvest_error
1, # territorial_zones_count
diff --git a/frontend/src/components/concept/ConceptProgramForm.tsx b/frontend/src/components/concept/ConceptProgramForm.tsx
index af41a801..7c8f32f7 100644
--- a/frontend/src/components/concept/ConceptProgramForm.tsx
+++ b/frontend/src/components/concept/ConceptProgramForm.tsx
@@ -48,6 +48,11 @@ const FLOOR_HEIGHT_MIN = 2.4;
const FLOOR_HEIGHT_MAX = 4.5;
const FLOOR_HEIGHT_DEFAULT = 3.0;
+// Ручное пятно секции (м). Границы зеркалят backend BuildingProgramItem
+// (footprint_w_m / footprint_d_m, ge=4 le=120).
+const FOOTPRINT_MIN = 4;
+const FOOTPRINT_MAX = 120;
+
// ── Public payload (lifted to the parent on submit) ───────────────────────────
/**
@@ -66,6 +71,10 @@ export interface ConceptProgramFormState {
floor_height_m: number;
/** Каталожный тип секции → building_program item `section_type`. */
section_type: string;
+ /** Пятно секции (ширина, м) → building_program item `footprint_w_m`. */
+ footprint_w_m: number;
+ /** Пятно секции (глубина, м) → building_program item `footprint_d_m`. */
+ footprint_d_m: number;
housing_class: HousingClass;
development_type: DevelopmentType;
land_cost_rub: number | null;
@@ -165,6 +174,11 @@ export function ConceptProgramForm({
const [floors, setFloors] = useState
- Пятно секции {nf0.format(selectedType.footprint_w_m)} ×{" "} - {nf0.format(selectedType.footprint_d_m)} м ≈{" "} - - {nf0.format(selectedType.footprint_sqm)} - {" "} - м² — задаётся типом секции. + ≈ {nf0.format(footprintSqm)} м² + на секцию + {footprintW == null && footprintD == null ? " (из типа)" : ""}.
)}