feat(section7): editable section footprint + корпус plural/program hint (#1953) #2102
9 changed files with 214 additions and 29 deletions
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -685,12 +685,15 @@ def test_nspd_path_used_when_zouit_count_nonzero() -> None:
|
||||||
Проверяем source='nspd-quarter-dump' (не cad_zouit).
|
Проверяем source='nspd-quarter-dump' (не cad_zouit).
|
||||||
"""
|
"""
|
||||||
# Строка дампа: все поля через MagicMock
|
# Строка дампа: все поля через MagicMock
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
dump_row = MagicMock()
|
dump_row = MagicMock()
|
||||||
dump_row.__getitem__ = lambda self, i: [
|
dump_row.__getitem__ = lambda self, i: [
|
||||||
"66:41:0603016", # quarter_cad
|
"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
|
100, # total_features
|
||||||
None, # harvest_error
|
None, # harvest_error
|
||||||
1, # territorial_zones_count
|
1, # territorial_zones_count
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 корпус / 2–4 корпуса / 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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,14 @@ export interface BuildingProgramItem {
|
||||||
floors: number;
|
floors: number;
|
||||||
/** Сколько секций этого типа разместить, 1–50. */
|
/** Сколько секций этого типа разместить, 1–50. */
|
||||||
count: number;
|
count: number;
|
||||||
|
/**
|
||||||
|
* Опциональное ручное пятно секции (ширина, м), 4–120. None / не задано →
|
||||||
|
* габариты берёт бэк из каталога по `section_type`. Должно задаваться вместе с
|
||||||
|
* `footprint_d_m` (частичное задание бэк игнорирует). ADDITIVE (Stage 3c).
|
||||||
|
*/
|
||||||
|
footprint_w_m?: number | null;
|
||||||
|
/** Опциональное ручное пятно секции (глубина, м), 4–120. См. `footprint_w_m`. */
|
||||||
|
footprint_d_m?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConceptInput {
|
export interface ConceptInput {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue