feat(section7): editable section footprint + корпус plural/program hint (#1953)
Some checks failed
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m12s
CI / openapi-codegen-check (pull_request) Successful in 2m4s
CI / backend-tests (pull_request) Failing after 14m21s

PR-B (follow-up к unify-concept). Закрывает «вписать пятно» из round-3:
- BuildingProgramItem получает опциональные footprint_w_m/footprint_d_m
  (float|None, ge=4 le=120, default None → каталог; ADDITIVE, byte-for-byte
  backward-compat). place_program кладёт ручное пятно когда заданы ОБА габарита,
  иначе каталожное. api-types.ts регенерирован под новую схему.
- ConceptProgramForm: пятно секции теперь редактируемое (ширина × глубина);
  пусто → возврат к каталогу; Σ пятно и ориентир КСИТ считаются от эффективного
  габарита. building_program несёт footprint в Section7Concept.
- ConceptVariantsResult: грамматика «корпус» (1 корпус / 2–4 корпуса / 5+
  корпусов) в headline / подписи карты / partial-fit; program-вариант получает
  свой хинт вместо STRATEGY_HINTS[balanced].
- Тесты place_program: footprint-override и partial-footprint fallback.
This commit is contained in:
Light1YT 2026-06-30 13:59:14 +05:00
parent e359fb4b95
commit 83a063f0ee
8 changed files with 209 additions and 27 deletions

View file

@ -20,6 +20,18 @@ class BuildingProgramItem(BaseModel):
floors: int = Field(..., ge=1, le=40, description="Floors for this house-type group") 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") 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): class HouseTypeCatalogItem(BaseModel):

View file

@ -273,13 +273,18 @@ def place_program(
for item in program: for item in program:
house = catalog.get_house_type(item.section_type) house = catalog.get_house_type(item.section_type)
requested += item.count 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 placed_for_item = 0
for cell in parcel.grid: for cell in parcel.grid:
if placed_for_item >= item.count: if placed_for_item >= item.count:
break break
footprint = _centered_footprint( footprint = _centered_footprint(cell.cx, cell.cy, fp_w, fp_d)
cell.cx, cell.cy, house.footprint_w_m, house.footprint_d_m
)
if placer.try_place(footprint, buildable, half_gap): if placer.try_place(footprint, buildable, half_gap):
placed_sections.append( placed_sections.append(
_PlacedSection( _PlacedSection(

View file

@ -9,8 +9,8 @@ from __future__ import annotations
from shapely.geometry import shape from shapely.geometry import shape
from app.schemas.concept import ConceptInput from app.schemas.concept import BuildingProgramItem, ConceptInput
from app.services.generative import geometry, placement from app.services.generative import catalog, geometry, placement
_PARCEL_COORDS = [ _PARCEL_COORDS = [
[60.60, 56.830], [60.60, 56.830],
@ -67,10 +67,7 @@ def test_max_area_denser_than_max_insolation() -> None:
parcel = geometry.parse_parcel(payload) parcel = geometry.parse_parcel(payload)
variants = {v.strategy: v for v in placement.place_all_strategies(parcel, payload)} variants = {v.strategy: v for v in placement.place_all_strategies(parcel, payload)}
# Максимум площади даёт большее пятно/жилую, чем максимум инсоляции. # Максимум площади даёт большее пятно/жилую, чем максимум инсоляции.
assert ( assert variants["max_area"].teap.built_area_sqm > variants["max_insolation"].teap.built_area_sqm
variants["max_area"].teap.built_area_sqm
> variants["max_insolation"].teap.built_area_sqm
)
assert ( assert (
variants["max_area"].teap.residential_area_sqm variants["max_area"].teap.residential_area_sqm
> variants["max_insolation"].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 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: def test_placement_deterministic() -> None:
payload = _payload() payload = _payload()
p1 = geometry.parse_parcel(payload) p1 = geometry.parse_parcel(payload)

View file

@ -48,6 +48,11 @@ const FLOOR_HEIGHT_MIN = 2.4;
const FLOOR_HEIGHT_MAX = 4.5; const FLOOR_HEIGHT_MAX = 4.5;
const FLOOR_HEIGHT_DEFAULT = 3.0; 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) ─────────────────────────── // ── Public payload (lifted to the parent on submit) ───────────────────────────
/** /**
@ -66,6 +71,10 @@ export interface ConceptProgramFormState {
floor_height_m: number; floor_height_m: number;
/** Каталожный тип секции → building_program item `section_type`. */ /** Каталожный тип секции → building_program item `section_type`. */
section_type: string; 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; housing_class: HousingClass;
development_type: DevelopmentType; development_type: DevelopmentType;
land_cost_rub: number | null; land_cost_rub: number | null;
@ -165,6 +174,11 @@ export function ConceptProgramForm({
const [floors, setFloors] = useState<number>(FLOORS_FALLBACK); const [floors, setFloors] = useState<number>(FLOORS_FALLBACK);
const [floorHeight, setFloorHeight] = useState<number>(FLOOR_HEIGHT_DEFAULT); const [floorHeight, setFloorHeight] = useState<number>(FLOOR_HEIGHT_DEFAULT);
const [sectionType, setSectionType] = useState<string>(""); const [sectionType, setSectionType] = useState<string>("");
// Ручное пятно: null → используем габариты каталога по типу секции; число →
// пользователь «вписал» свой габарит (override). Сбрасывается в null при смене
// типа (новый тип несёт свои каталожные габариты).
const [footprintW, setFootprintW] = useState<number | null>(null);
const [footprintD, setFootprintD] = useState<number | null>(null);
const [housingClass, setHousingClass] = useState<HousingClass>( const [housingClass, setHousingClass] = useState<HousingClass>(
seeded.housing_class, seeded.housing_class,
); );
@ -191,9 +205,16 @@ export function ConceptProgramForm({
[catalog, sectionType], [catalog, sectionType],
); );
// Эффективное пятно секции: ручной override (footprintW/D) или каталог по типу.
const effFootprintW = footprintW ?? selectedType?.footprint_w_m ?? null;
const effFootprintD = footprintD ?? selectedType?.footprint_d_m ?? null;
// ── Derived read-only readouts (closes «видеть характеристики» complaint) ── // ── Derived read-only readouts (closes «видеть характеристики» complaint) ──
const buildingHeightM = floors * floorHeight; const buildingHeightM = floors * floorHeight;
const footprintSqm = selectedType?.footprint_sqm ?? null; const footprintSqm =
effFootprintW != null && effFootprintD != null
? effFootprintW * effFootprintD
: null;
const totalFootprintSqm = const totalFootprintSqm =
footprintSqm != null ? corpuses * footprintSqm : null; footprintSqm != null ? corpuses * footprintSqm : null;
const ksitEstimate = const ksitEstimate =
@ -207,11 +228,14 @@ export function ConceptProgramForm({
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (disabled || sectionType === "") return; if (disabled || sectionType === "") return;
if (effFootprintW == null || effFootprintD == null) return;
onSubmit({ onSubmit({
corpuses, corpuses,
floors, floors,
floor_height_m: floorHeight, floor_height_m: floorHeight,
section_type: sectionType, section_type: sectionType,
footprint_w_m: effFootprintW,
footprint_d_m: effFootprintD,
housing_class: housingClass, housing_class: housingClass,
development_type: developmentType, development_type: developmentType,
land_cost_rub: landCostRub, land_cost_rub: landCostRub,
@ -223,6 +247,9 @@ export function ConceptProgramForm({
setSectionType(nextType); setSectionType(nextType);
const ht = catalog.find((c) => c.section_type === nextType); const ht = catalog.find((c) => c.section_type === nextType);
if (ht && ht.default_floors > 0) setFloors(ht.default_floors); if (ht && ht.default_floors > 0) setFloors(ht.default_floors);
// Смена типа сбрасывает ручной override пятна на каталожные габариты нового типа.
setFootprintW(null);
setFootprintD(null);
} }
return ( return (
@ -375,7 +402,7 @@ export function ConceptProgramForm({
</p> </p>
</div> </div>
{/* 4. Тип секции + пятно секции readout. */} {/* 4. Тип секции. */}
<div> <div>
<label htmlFor="cpf-section-type" style={fieldLabelStyle}> <label htmlFor="cpf-section-type" style={fieldLabelStyle}>
Тип секции Тип секции
@ -394,14 +421,69 @@ export function ConceptProgramForm({
</option> </option>
))} ))}
</select> </select>
{selectedType && ( <p style={helperStyle}>
Габариты пятна по умолчанию из типа секции; ниже можно вписать
свои.
</p>
</div>
{/* 4b. Пятно секции — редактируемое (ширина × глубина). */}
<div>
<span style={fieldLabelStyle}>
Пятно секции, м (ширина × глубина)
</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="number"
aria-label="Ширина пятна секции, м"
min={FOOTPRINT_MIN}
max={FOOTPRINT_MAX}
step={1}
value={effFootprintW ?? ""}
disabled={!catalogReady}
onChange={(e) =>
// Пусто → null (вернуться к каталожным габаритам), иначе clamp.
setFootprintW(
e.target.value.trim() === ""
? null
: clampFloat(
Number(e.target.value),
FOOTPRINT_MIN,
FOOTPRINT_MAX,
),
)
}
style={{ ...controlStyle, flex: 1 }}
/>
<span style={{ color: "var(--fg-tertiary)", fontSize: 14 }}>×</span>
<input
type="number"
aria-label="Глубина пятна секции, м"
min={FOOTPRINT_MIN}
max={FOOTPRINT_MAX}
step={1}
value={effFootprintD ?? ""}
disabled={!catalogReady}
onChange={(e) =>
// Пусто → null (вернуться к каталожным габаритам), иначе clamp.
setFootprintD(
e.target.value.trim() === ""
? null
: clampFloat(
Number(e.target.value),
FOOTPRINT_MIN,
FOOTPRINT_MAX,
),
)
}
style={{ ...controlStyle, flex: 1 }}
/>
</div>
{footprintSqm != null && (
<p style={helperStyle}> <p style={helperStyle}>
Пятно секции {nf0.format(selectedType.footprint_w_m)} ×{" "} <span style={tabularStyle}>{nf0.format(footprintSqm)}</span> м²
{nf0.format(selectedType.footprint_d_m)} м {" "} на секцию
<span style={tabularStyle}> {footprintW == null && footprintD == null ? " (из типа)" : ""}.
{nf0.format(selectedType.footprint_sqm)}
</span>{" "}
м² задаётся типом секции.
</p> </p>
)} )}
</div> </div>

View file

@ -91,6 +91,32 @@ function variantLabel(variant: ConceptVariant): string {
: STRATEGY_LABELS[variant.strategy]; : STRATEGY_LABELS[variant.strategy];
} }
/**
* Variant subtitle hint: program variants get a program-appropriate sentence
* (the 1b STRATEGY_HINTS speak of «приоритет площади/инсоляции/баланс», which
* misdescribes a user-defined program reported under the "balanced" slot).
*/
function variantHint(variant: ConceptVariant): string {
return isProgramVariant(variant)
? "Раскладка заданной программы по геометрии участка."
: STRATEGY_HINTS[variant.strategy];
}
/** Russian plural for «корпус» (1 корпус / 24 корпуса / 5+ корпусов). */
function pluralizeCorpus(n: number): string {
const mod100 = n % 100;
const mod10 = n % 10;
if (mod100 >= 11 && mod100 <= 14) return "корпусов";
if (mod10 === 1) return "корпус";
if (mod10 >= 2 && mod10 <= 4) return "корпуса";
return "корпусов";
}
/** «3 корпуса» / «1 корпус» / «12 корпусов» — count + agreed plural. */
function formatCorpuses(n: number): string {
return `${formatInt(n)} ${pluralizeCorpus(n)}`;
}
function formatPct(fraction: number): string { function formatPct(fraction: number): string {
return `${(fraction * 100).toFixed(1)}%`; return `${(fraction * 100).toFixed(1)}%`;
} }
@ -149,8 +175,8 @@ function VariantPanel({ parcel, variant }: PanelProps) {
}} }}
> >
<div style={{ fontSize: 15, fontWeight: 600 }}> <div style={{ fontSize: 15, fontWeight: 600 }}>
{variantLabel(variant)}: {formatInt(buildingCount(variant))} корпусов {variantLabel(variant)}: {formatCorpuses(buildingCount(variant))} ·{" "}
· {formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "} {formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "}
{formatInt(teap.apartments_count)} квартир · чистая прибыль{" "} {formatInt(teap.apartments_count)} квартир · чистая прибыль{" "}
{formatMoneyCompact(financial.net_profit_rub)} · ROI{" "} {formatMoneyCompact(financial.net_profit_rub)} · ROI{" "}
{formatPct(financial.roi)} {formatPct(financial.roi)}
@ -164,9 +190,8 @@ function VariantPanel({ parcel, variant }: PanelProps) {
color: "var(--fg-on-dark-muted)", color: "var(--fg-on-dark-muted)",
}} }}
> >
{STRATEGY_HINTS[variant.strategy]} NPV{" "} {variantHint(variant)} NPV {formatMoneyCompact(financial.npv_rub)} ·
{formatMoneyCompact(financial.npv_rub)} · IRR{" "} IRR {formatPct(financial.irr)}
{formatPct(financial.irr)}
{financial.irr_is_proxy ? " (оценочный)" : ""} · окупаемость{" "} {financial.irr_is_proxy ? " (оценочный)" : ""} · окупаемость{" "}
{formatPayback(financial.payback_months)} · плотность (FAR){" "} {formatPayback(financial.payback_months)} · плотность (FAR){" "}
{teap.density.toLocaleString("ru-RU", { {teap.density.toLocaleString("ru-RU", {
@ -193,8 +218,8 @@ function VariantPanel({ parcel, variant }: PanelProps) {
}} }}
> >
Размещено {formatInt(partialFit.placed)} из{" "} Размещено {formatInt(partialFit.placed)} из{" "}
{formatInt(partialFit.requested)} корпусов участок вмещает меньше, {formatCorpuses(partialFit.requested)} участок вмещает меньше, чем в
чем в заданной программе. ТЭП и финмодель рассчитаны по фактически заданной программе. ТЭП и финмодель рассчитаны по фактически
размещённым корпусам. размещённым корпусам.
</div> </div>
)} )}
@ -202,9 +227,9 @@ function VariantPanel({ parcel, variant }: PanelProps) {
{/* Placement map */} {/* Placement map */}
<Section <Section
title="Размещение застройки" title="Размещение застройки"
subtitle={`Полигон участка (пунктир) и сгенерированные корпуса (заливка) — ${formatInt( subtitle={`Полигон участка (пунктир) и сгенерированные корпуса (заливка) — ${formatCorpuses(
buildingCount(variant), buildingCount(variant),
)} корпусов.`} )}.`}
> >
<ConceptResultMap <ConceptResultMap
parcel={parcel} parcel={parcel}

View file

@ -180,6 +180,8 @@ export function Section7Concept({ analysis }: Props) {
section_type: state.section_type, section_type: state.section_type,
floors: state.floors, floors: state.floors,
count: state.corpuses, count: state.corpuses,
footprint_w_m: state.footprint_w_m,
footprint_d_m: state.footprint_d_m,
}, },
]; ];
concept.mutate({ concept.mutate({
@ -217,6 +219,8 @@ export function Section7Concept({ analysis }: Props) {
floors: def.default_floors > 0 ? def.default_floors : FLOORS_FALLBACK, floors: def.default_floors > 0 ? def.default_floors : FLOORS_FALLBACK,
floor_height_m: 3.0, floor_height_m: 3.0,
section_type: def.section_type, section_type: def.section_type,
footprint_w_m: def.footprint_w_m,
footprint_d_m: def.footprint_d_m,
housing_class: seeded.housing_class, housing_class: seeded.housing_class,
development_type: seeded.development_type, development_type: seeded.development_type,
land_cost_rub: seeded.land_cost_rub, land_cost_rub: seeded.land_cost_rub,

View file

@ -3046,6 +3046,16 @@ export interface components {
* @description Number of sections to place * @description Number of sections to place
*/ */
count: number; count: number;
/**
* Footprint W M
* @description Optional manual section footprint width, m
*/
footprint_w_m?: number | null;
/**
* Footprint D M
* @description Optional manual section footprint depth, m
*/
footprint_d_m?: number | null;
}; };
/** /**
* BulkGeoEnqueueRequest * BulkGeoEnqueueRequest

View file

@ -39,6 +39,14 @@ export interface BuildingProgramItem {
floors: number; floors: number;
/** Сколько секций этого типа разместить, 150. */ /** Сколько секций этого типа разместить, 150. */
count: number; count: number;
/**
* Опциональное ручное пятно секции (ширина, м), 4120. None / не задано
* габариты берёт бэк из каталога по `section_type`. Должно задаваться вместе с
* `footprint_d_m` (частичное задание бэк игнорирует). ADDITIVE (Stage 3c).
*/
footprint_w_m?: number | null;
/** Опциональное ручное пятно секции (глубина, м), 4120. См. `footprint_w_m`. */
footprint_d_m?: number | null;
} }
export interface ConceptInput { export interface ConceptInput {