From 177b08294c00cbc1a3cb7e13827cb5ac16435c9e Mon Sep 17 00:00:00 2001 From: DenDanskeMine Date: Wed, 22 Jul 2026 13:32:46 +0000 Subject: [PATCH 001/105] 3D groundwork: real-world scale on plans, trays and racks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of the 3D/planning track — purely additive fields, all defaulted or nullable so existing data needs no entry: - FloorPlan.cell_mm (default 600 = a raised-floor tile) + ceiling_mm (3000): gives the abstract grid physical meaning for the 3D room view, route-length estimation and drawing scale bars. - FloorPlanTray.level (overhead/underfloor/floor) + elevation_mm (nullable, derives from level): where a run physically lives. - Rack.outer_width_mm / outer_depth_mm (nullable): cabinet footprint. - OPENING_MM extracted from rack-elevation.tsx into lib/faceplate-geometry.ts so 2D, 3D and drawings share one dimension source. - Forms: plan cell/ceiling, tray level/elevation inspector fields, rack outer dims. Docs: racks + floor-plans pages. Tests: round-trips + validator bounds. --- ...n_ceiling_mm_floorplan_cell_mm_and_more.py | 44 ++++++++ api/models.py | 43 +++++++ api/serializers.py | 5 +- api/tests.py | 19 ++++ api/tests_floorplan.py | 56 +++++++++ docs/dcim/racks.md | 4 + docs/features/floor-plans.md | 11 ++ frontend/src/components/floor-plan-form.tsx | 29 +++++ frontend/src/components/rack-elevation.tsx | 10 +- frontend/src/components/rack-form.tsx | 30 +++++ frontend/src/lib/api.ts | 21 ++++ frontend/src/lib/faceplate-geometry.ts | 11 ++ frontend/src/routes/floorplans.$id.tsx | 42 +++++++ openapi.yaml | 106 ++++++++++++++++++ 14 files changed, 421 insertions(+), 10 deletions(-) create mode 100644 api/migrations/0094_floorplan_ceiling_mm_floorplan_cell_mm_and_more.py diff --git a/api/migrations/0094_floorplan_ceiling_mm_floorplan_cell_mm_and_more.py b/api/migrations/0094_floorplan_ceiling_mm_floorplan_cell_mm_and_more.py new file mode 100644 index 00000000..8c20496b --- /dev/null +++ b/api/migrations/0094_floorplan_ceiling_mm_floorplan_cell_mm_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.15 on 2026-07-22 13:13 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0093_modulebaytemplate_default_module_type'), + ] + + operations = [ + migrations.AddField( + model_name='floorplan', + name='ceiling_mm', + field=models.PositiveSmallIntegerField(default=3000, help_text='Room ceiling height, in millimetres.', validators=[django.core.validators.MinValueValidator(1000), django.core.validators.MaxValueValidator(20000)]), + ), + migrations.AddField( + model_name='floorplan', + name='cell_mm', + field=models.PositiveSmallIntegerField(default=600, help_text='Physical size of one grid cell, in millimetres.', validators=[django.core.validators.MinValueValidator(50), django.core.validators.MaxValueValidator(5000)]), + ), + migrations.AddField( + model_name='floorplantray', + name='elevation_mm', + field=models.IntegerField(blank=True, help_text='Height above finished floor in millimetres (negative = below the raised floor). Blank derives from the level: overhead → ceiling − 300, underfloor → −300, floor → 0.', null=True, validators=[django.core.validators.MinValueValidator(-2000), django.core.validators.MaxValueValidator(20000)]), + ), + migrations.AddField( + model_name='floorplantray', + name='level', + field=models.CharField(choices=[('overhead', 'Overhead'), ('underfloor', 'Underfloor'), ('floor', 'Floor level')], default='overhead', max_length=16), + ), + migrations.AddField( + model_name='rack', + name='outer_depth_mm', + field=models.PositiveSmallIntegerField(blank=True, help_text='Cabinet outer depth in millimetres (blank = 1000).', null=True, validators=[django.core.validators.MinValueValidator(100), django.core.validators.MaxValueValidator(3000)]), + ), + migrations.AddField( + model_name='rack', + name='outer_width_mm', + field=models.PositiveSmallIntegerField(blank=True, help_text='Cabinet outer width in millimetres (blank = derived).', null=True, validators=[django.core.validators.MinValueValidator(100), django.core.validators.MaxValueValidator(2000)]), + ), + ] diff --git a/api/models.py b/api/models.py index 5a05f929..796ca660 100644 --- a/api/models.py +++ b/api/models.py @@ -2994,6 +2994,19 @@ class Rack(NumIdMixin, TimestampedModel, CustomFieldsMixin, TaggableMixin): desc_units = models.BooleanField( default=False, help_text="Number units top-to-bottom instead of bottom-up.", ) + # Cabinet outer dimensions — the physical footprint (frame included), used + # by the 3D room view and scaled drawings. Blank = plausible render + # defaults (depth 1000 mm; width = rail width + 150 mm frame). + outer_width_mm = models.PositiveSmallIntegerField( + null=True, blank=True, + validators=[MinValueValidator(100), MaxValueValidator(2000)], + help_text="Cabinet outer width in millimetres (blank = derived).", + ) + outer_depth_mm = models.PositiveSmallIntegerField( + null=True, blank=True, + validators=[MinValueValidator(100), MaxValueValidator(3000)], + help_text="Cabinet outer depth in millimetres (blank = 1000).", + ) description = models.TextField(blank=True) class Meta: @@ -4616,6 +4629,19 @@ class FloorPlan(NumIdMixin, TimestampedModel, CustomFieldsMixin, TaggableMixin): background_opacity = models.PositiveSmallIntegerField( default=60, validators=[MaxValueValidator(100)], help_text="Percent." ) + # Real-world scale. The grid itself is abstract; these give it physical + # meaning for the 3D room view, route-length estimation, and drawing scale + # bars. Defaults are deliberately plausible (600 mm = a standard raised + # floor tile) so existing plans render sensibly with zero data entry. + cell_mm = models.PositiveSmallIntegerField( + default=600, validators=[MinValueValidator(50), MaxValueValidator(5000)], + help_text="Physical size of one grid cell, in millimetres.", + ) + ceiling_mm = models.PositiveSmallIntegerField( + default=3000, + validators=[MinValueValidator(1000), MaxValueValidator(20000)], + help_text="Room ceiling height, in millimetres.", + ) # View prefs (default zoom/pan, overlay mode, grid on/off) — free schema, # same trick as TopologyView.state, so it evolves without migrations. state = models.JSONField(default=dict, blank=True) @@ -4911,6 +4937,23 @@ class FloorPlanTray(TimestampedModel): # "ladder", "underfloor"… whatever the shop calls it. kind = models.CharField(max_length=32, blank=True, default="") color = models.CharField(max_length=7, blank=True, default="") + # Vertical placement — where the run physically lives. Drives the 3D + # render height and the vertical-drop term in route-length estimation. + LEVEL_CHOICES = [ + ("overhead", "Overhead"), + ("underfloor", "Underfloor"), + ("floor", "Floor level"), + ] + level = models.CharField( + max_length=16, choices=LEVEL_CHOICES, default="overhead" + ) + elevation_mm = models.IntegerField( + null=True, blank=True, + validators=[MinValueValidator(-2000), MaxValueValidator(20000)], + help_text="Height above finished floor in millimetres (negative = " + "below the raised floor). Blank derives from the level: overhead → " + "ceiling − 300, underfloor → −300, floor → 0.", + ) # [[x, y], …] in cell-corner coordinates (integers along grid lines). points = models.JSONField(default=list) description = models.TextField(blank=True, default="") diff --git a/api/serializers.py b/api/serializers.py index d9182d01..686c98a2 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -3426,6 +3426,7 @@ class Meta: fields = ["id", "numid", "name", "facility_id", "site", "site_id", "role", "role_id", "status", "status_id", "location", "location_id", "width", "u_height", + "outer_width_mm", "outer_depth_mm", "max_weight", "max_weight_unit", "total_weight_kg", "max_weight_kg", "power", "starting_unit", "desc_units", "description", @@ -5109,7 +5110,8 @@ def validate(self, attrs): class Meta: model = FloorPlan fields = ["id", "numid", "name", "location", "location_id", "site", - "grid_width", "grid_height", "background_image", + "grid_width", "grid_height", "cell_mm", "ceiling_mm", + "background_image", "background_opacity", "state", "description", "tile_count", "tags", "tag_ids", "custom_fields", "created_at", "updated_at"] @@ -5291,6 +5293,7 @@ def validate_points(self, v): class Meta: model = FloorPlanTray fields = ["id", "floor_plan_id", "name", "kind", "color", "points", + "level", "elevation_mm", "description", "cables", "cable_ids", "created_at", "updated_at"] read_only_fields = ["id", "cables", "created_at", "updated_at"] diff --git a/api/tests.py b/api/tests.py index e443eda1..08ed0644 100644 --- a/api/tests.py +++ b/api/tests.py @@ -610,3 +610,22 @@ def test_used_units_counts_shared_unit_once(self): self._post("sw2", self.dt_half, 10, side="right") r = self.client.get(f"/api/racks/{self.rack.id}/") self.assertEqual(r.json()["used_units"], 1) + + def test_outer_dimensions_roundtrip(self): + # Blank by default (renderers derive plausible values). + r = self.client.get(f"/api/racks/{self.rack.id}/") + self.assertIsNone(r.json()["outer_width_mm"]) + self.assertIsNone(r.json()["outer_depth_mm"]) + r = self.client.patch( + f"/api/racks/{self.rack.id}/", + {"outer_width_mm": 600, "outer_depth_mm": 1200}, + format="json", + ) + self.assertEqual(r.status_code, 200, r.content) + self.assertEqual(r.json()["outer_width_mm"], 600) + self.assertEqual(r.json()["outer_depth_mm"], 1200) + # Validator bounds enforced. + r = self.client.patch( + f"/api/racks/{self.rack.id}/", {"outer_depth_mm": 9}, format="json" + ) + self.assertEqual(r.status_code, 400) diff --git a/api/tests_floorplan.py b/api/tests_floorplan.py index 1f339c48..ee0eac1f 100644 --- a/api/tests_floorplan.py +++ b/api/tests_floorplan.py @@ -144,6 +144,32 @@ def test_crud_and_tenant_isolation(self): self.assertEqual(resp.status_code, 200) self.assertEqual(resp.json()["state"]["overlay"], "power") + def test_physical_scale_defaults_and_roundtrip(self): + # Defaults give existing plans plausible real-world scale for free. + resp = self.client.post( + "/api/floor-plans/", + {"name": "Hall B", "location_id": str(self.loc.id)}, + format="json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + body = resp.json() + self.assertEqual(body["cell_mm"], 600) + self.assertEqual(body["ceiling_mm"], 3000) + # And they're editable within their validator bounds. + resp = self.client.patch( + f"/api/floor-plans/{body['id']}/", + {"cell_mm": 500, "ceiling_mm": 2700}, + format="json", + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()["cell_mm"], 500) + self.assertEqual(resp.json()["ceiling_mm"], 2700) + # Out-of-range values are rejected, not clamped silently. + resp = self.client.patch( + f"/api/floor-plans/{body['id']}/", {"cell_mm": 10}, format="json" + ) + self.assertEqual(resp.status_code, 400) + class TileTests(_Base): def setUp(self): @@ -454,6 +480,36 @@ def test_tray_tenant_isolation(self): got = self.client.get("/api/floor-plan-trays/").json() self.assertEqual(got["count"], 0) + def test_tray_level_and_elevation_roundtrip(self): + resp = self.client.post( + "/api/floor-plan-trays/", + {"floor_plan_id": str(self.plan.id), "name": "OH-1", + "points": [[0, 0], [4, 0]]}, + format="json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + body = resp.json() + # Defaults: overhead run, elevation derived (null) until set. + self.assertEqual(body["level"], "overhead") + self.assertIsNone(body["elevation_mm"]) + + resp = self.client.patch( + f"/api/floor-plan-trays/{body['id']}/", + {"level": "underfloor", "elevation_mm": -300}, + format="json", + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()["level"], "underfloor") + self.assertEqual(resp.json()["elevation_mm"], -300) + + # Unknown level value rejected by the choices validator. + resp = self.client.patch( + f"/api/floor-plan-trays/{body['id']}/", + {"level": "orbit"}, + format="json", + ) + self.assertEqual(resp.status_code, 400) + def test_tile_rack_filter(self): tt = FloorTileType.objects.create( tenant=self.tenant, name="Rack", slug="rack" diff --git a/docs/dcim/racks.md b/docs/dcim/racks.md index 239855d7..896f0b78 100644 --- a/docs/dcim/racks.md +++ b/docs/dcim/racks.md @@ -13,6 +13,10 @@ familiar front/rear diagram showing what's mounted in each rack unit. 2. Name it and set its **height** in rack units (e.g. 42U) and **starting unit** (usually 1). 3. Optionally assign a **site**, a **rack role**, and tags. +4. Optionally record the cabinet's **outer width / depth (mm)** — the physical + footprint including the frame. These drive the 3D room view and scaled + drawings; left blank, plausible defaults are used (depth 1000 mm, width + derived from the rail width plus a 150 mm frame). ### Rack roles diff --git a/docs/features/floor-plans.md b/docs/features/floor-plans.md index e57b3a06..b76f36cb 100644 --- a/docs/features/floor-plans.md +++ b/docs/features/floor-plans.md @@ -55,6 +55,11 @@ page). A plan belongs to a location, has a grid (default 24×16 cells, up to 512×512), and can carry an uploaded **background image** — a blueprint or photo scaled under the grid with adjustable opacity. +The grid also carries a **real-world scale**: a **cell size** in millimetres +(default 600 — one standard raised-floor tile) and a **ceiling height** +(default 3000). Existing plans keep working untouched; the scale powers the +3D view, route-length estimation, and the scale bar on printed drawings. + ## Floors A location can hold **several plans — its floors**. Name them "Basement", @@ -136,6 +141,12 @@ through each one. Hand the PNG to whoever's pulling cable. - **Assign cables**: select a tray → its inspector lists the cables in it, with **Add cable** (searches all cables) and a × to remove. The tray shows its cable count on the canvas and in the rail. +- **Level & elevation**: each tray records where it physically lives — + **Overhead** (the default), **Underfloor**, or **Floor level** — plus an + optional exact **elevation in mm**. Leave elevation blank and it derives + from the level (overhead → ceiling − 300, underfloor → −300, floor → 0). + This drives the tray's height in the 3D view and the vertical-drop term in + route-length estimation. - Trays render **above tiles** so the run is legible on the print, and they export with the PNG. - Cables mode is edit-gated; viewers still see the trays. diff --git a/frontend/src/components/floor-plan-form.tsx b/frontend/src/components/floor-plan-form.tsx index 4c8f77b7..31647fec 100644 --- a/frontend/src/components/floor-plan-form.tsx +++ b/frontend/src/components/floor-plan-form.tsx @@ -44,6 +44,8 @@ export function FloorPlanForm({ ) const [gridWidth, setGridWidth] = useState(String(plan?.grid_width ?? 24)) const [gridHeight, setGridHeight] = useState(String(plan?.grid_height ?? 16)) + const [cellMm, setCellMm] = useState(String(plan?.cell_mm ?? 600)) + const [ceilingMm, setCeilingMm] = useState(String(plan?.ceiling_mm ?? 3000)) const [description, setDescription] = useState(plan?.description ?? "") const sites = useSiteOptions() @@ -67,6 +69,11 @@ export function FloorPlanForm({ location_id: locationId ?? undefined, grid_width: Math.min(512, Math.max(1, parseInt(gridWidth, 10) || 24)), grid_height: Math.min(512, Math.max(1, parseInt(gridHeight, 10) || 16)), + cell_mm: Math.min(5000, Math.max(50, parseInt(cellMm, 10) || 600)), + ceiling_mm: Math.min( + 20000, + Math.max(1000, parseInt(ceilingMm, 10) || 3000) + ), description, } if (isEdit) @@ -162,6 +169,28 @@ export function FloorPlanForm({ error={fieldErrors.grid_height} /> + + + + = { - 10: 222, - 19: 450, - 21: 500, - 23: 551, -} export type RackFace = "front" | "rear" export type RackDisplayMode = "names" | "images" | "render" diff --git a/frontend/src/components/rack-form.tsx b/frontend/src/components/rack-form.tsx index 223199ab..9657c998 100644 --- a/frontend/src/components/rack-form.tsx +++ b/frontend/src/components/rack-form.tsx @@ -60,6 +60,12 @@ export function RackForm({ rack, onSaved, onCancel }: RackFormProps) { rack ? String(rack.starting_unit) : "1" ) const [descUnits, setDescUnits] = useState(rack?.desc_units ?? false) + const [outerWidth, setOuterWidth] = useState( + rack?.outer_width_mm != null ? String(rack.outer_width_mm) : "" + ) + const [outerDepth, setOuterDepth] = useState( + rack?.outer_depth_mm != null ? String(rack.outer_depth_mm) : "" + ) const [maxWeight, setMaxWeight] = useState(rack?.max_weight ?? "") const [maxWeightUnit, setMaxWeightUnit] = useState( rack?.max_weight_unit || "kg" @@ -84,6 +90,8 @@ export function RackForm({ rack, onSaved, onCancel }: RackFormProps) { setUHeight(String(rack.u_height)) setStartingUnit(String(rack.starting_unit)) setDescUnits(rack.desc_units) + setOuterWidth(rack.outer_width_mm != null ? String(rack.outer_width_mm) : "") + setOuterDepth(rack.outer_depth_mm != null ? String(rack.outer_depth_mm) : "") setMaxWeight(rack.max_weight ?? "") setMaxWeightUnit(rack.max_weight_unit || "kg") setDescription(rack.description) @@ -132,6 +140,8 @@ export function RackForm({ rack, onSaved, onCancel }: RackFormProps) { u_height: uHeight.trim() === "" ? 42 : Number(uHeight), starting_unit: startingUnit.trim() === "" ? 1 : Number(startingUnit), desc_units: descUnits, + outer_width_mm: outerWidth.trim() === "" ? null : Number(outerWidth), + outer_depth_mm: outerDepth.trim() === "" ? null : Number(outerDepth), max_weight: maxWeight.trim() === "" ? null : maxWeight.trim(), max_weight_unit: maxWeight.trim() === "" ? "" : maxWeightUnit, description: description.trim(), @@ -306,6 +316,26 @@ export function RackForm({ rack, onSaved, onCancel }: RackFormProps) { onChange={setStartingUnit} error={fieldErrors.starting_unit} /> + + @@ -5307,6 +5313,10 @@ export interface FloorPlan { site: SiteOption grid_width: number grid_height: number + /** Physical size of one grid cell (mm) — default 600, a raised-floor tile. */ + cell_mm: number + /** Room ceiling height (mm). */ + ceiling_mm: number /** Relative /media/… URL for the blueprint under the grid, or null. */ background_image: string | null background_opacity: number @@ -5331,6 +5341,8 @@ export interface TrayCableMini { /** A cable tray / conduit run: a named polyline on the plan's half-cell * lattice, with the physical cables routed through it. */ +export type TrayLevel = "overhead" | "underfloor" | "floor" + export interface FloorPlanTray { id: string name: string @@ -5339,6 +5351,11 @@ export interface FloorPlanTray { color: string /** [[x, y], …] in cell units, snapped to 0.5 steps. */ points: [number, number][] + /** Vertical placement — drives 3D height + route-length drops. */ + level: TrayLevel + /** Height above finished floor (mm, negative = underfloor). Null derives + * from level: overhead → ceiling−300, underfloor → −300, floor → 0. */ + elevation_mm: number | null description: string cables: TrayCableMini[] created_at: string @@ -5351,6 +5368,8 @@ export interface FloorPlanTrayWritePayload { kind?: string color?: string points?: [number, number][] + level?: TrayLevel + elevation_mm?: number | null description?: string cable_ids?: string[] } @@ -5417,6 +5436,8 @@ export interface FloorPlanWritePayload { location_id?: string grid_width?: number grid_height?: number + cell_mm?: number + ceiling_mm?: number background_opacity?: number state?: Record description?: string diff --git a/frontend/src/lib/faceplate-geometry.ts b/frontend/src/lib/faceplate-geometry.ts index e38c3870..b65b5cdb 100644 --- a/frontend/src/lib/faceplate-geometry.ts +++ b/frontend/src/lib/faceplate-geometry.ts @@ -23,6 +23,17 @@ export const PANEL_MM = { groupGap: 6, } as const +/** Usable rack opening (mm) per nominal rail width in inches — 19″ is + * EIA-310's 450mm; the rest scale with the rail spacing (10″ half-racks, + * 21/23″ telco). Shared by the 2D elevation, the 3D room view, and (as a + * snapshot) the PDF drawings. */ +export const OPENING_MM: Record = { + 10: 222, + 19: 450, + 21: 500, + 23: 551, +} + export type ConnectorFamily = | "rj45" | "sfp" diff --git a/frontend/src/routes/floorplans.$id.tsx b/frontend/src/routes/floorplans.$id.tsx index f484d74b..79016d5c 100644 --- a/frontend/src/routes/floorplans.$id.tsx +++ b/frontend/src/routes/floorplans.$id.tsx @@ -43,10 +43,18 @@ import type { Paginated, PowerPanelOption, Rack, + TrayLevel, } from "@/lib/api" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { ColorPicker } from "@/components/ui/color-picker" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { Dialog, DialogContent, @@ -2217,6 +2225,9 @@ function TrayInspector({ }) { const [name, setName] = useState(tray.name) const [kind, setKind] = useState(tray.kind) + const [elevation, setElevation] = useState( + tray.elevation_mm != null ? String(tray.elevation_mm) : "" + ) return (