# Model a mass

> A podium and a tower as a simple volume. Change it with commands, check the zoning rules after each step, and undo. The last button turns it into walls, slabs and roofs.

- Live example: https://floorkit-examples.ploterr.com/floorkit/guides/model-a-mass
- Docs page: https://docs.ploterr.com/floorkit/guides/model-a-mass
- Category: Model a building
- API: `BuildingSession`, `Building`, `massToShell`, `massSegmentHeightCommand`, `evaluateZoning`
- Tags: mass, massing, floor area, zoning
- Package: `@opengeometry/floorkit`
- Source: `examples/src/guides/model-a-mass.example.ts`

Each example is one module exporting `meta` and `setup(ctx)`. The examples shell renders it in 3D and as a 2D plan (or on a sheet surface for drawing examples) and builds its controls from the element schema.

## Source

### examples/src/guides/model-a-mass.example.ts

```ts
import type { ExampleContext, ExampleMeta, ExampleResult } from '../app/lib/example';
import {
  addBuildingFromOutline,
  checkZoning,
  grossFloorArea,
  makeGroundParking,
  massToElements,
  setSetback,
  setSiteAndMass,
  setTowerHeight,
} from './code/model-a-mass';
import { closeBuilding, replaceBuilding, startBuilding } from './code/start-a-building';
import { action } from './support';

export const meta: ExampleMeta = {
  title: 'Model a mass',
  description:
    'A podium and a tower as a simple volume. Change it with commands, check the zoning rules after each step, and undo. The last button turns it into walls, slabs and roofs.',
  tags: ['mass', 'massing', 'floor area', 'zoning'],
  api: ['BuildingSession', 'Building', 'massToShell', 'massSegmentHeightCommand', 'evaluateZoning'],
  camera: { position: [22, 15, 20], target: [6, 5, 4] },
};

export function setup({ scene }: ExampleContext): ExampleResult {
  let session = startBuilding(scene);
  setSiteAndMass(session);
  const isMass = () => session.snapshot().buildings.some((building) => building.mass !== undefined);
  const step = (change: (current: typeof session) => void) => () => {
    if (isMass()) change(session);
  };
  return {
    controls: [
      action(
        'tower-height',
        'Raise the tower to 9 m',
        step((s) => setTowerHeight(s, 9)),
      ),
      action('ground-parking', 'Ground storey is parking', step(makeGroundParking)),
      action(
        'setback',
        'Setback of 5 m',
        step((s) => setSetback(s, 5)),
      ),
      action('add-building', 'Add a building', step(addBuildingFromOutline)),
      action('undo', 'Undo', () => {
        if (session.canUndo) session.undo(session.revision);
      }),
      action('mass-to-elements', 'Turn the mass into walls', () => {
        if (isMass()) session = replaceBuilding(scene, session, massToElements);
      }),
    ],
    report: () => {
      const zoning = isMass() ? checkZoning(session) : undefined;
      return [
        {
          title: 'Mass',
          columns: ['Quantity', 'Value'],
          rows: [
            {
              cells: [
                'Gross floor area',
                isMass()
                  ? `${(grossFloorArea(session) ?? 0).toFixed(0)} m²`
                  : 'The mass is now walls',
              ],
            },
            {
              cells: [
                'Floor area ratio',
                zoning ? zoning.metrics.floorAreaRatio.toFixed(2) : 'Not checked',
              ],
            },
            {
              cells: [
                'Zoning',
                zoning
                  ? zoning.compliant
                    ? 'Compliant'
                    : [...new Set(zoning.violations.map(({ type }) => type))].join(', ')
                  : 'Not checked',
              ],
            },
          ],
        },
      ];
    },
    expose: {
      get session() {
        return session;
      },
    },
    dispose: () => closeBuilding(scene, session),
  };
}
```

### examples/src/app/lib/example.ts

```ts
import type * as THREE from 'three';
import type { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import type { createModelRenderer, EditorChange, OPControl, VectorDrawing, WallNetwork } from '@opengeometry/floorkit';

export type EditTarget = THREE.Object3D & { ogType: string };

export type PlanRenderer = Awaited<ReturnType<typeof createModelRenderer>>;

export interface ExampleMeta {
  title: string;
  description: string;
  tags?: readonly string[];
  api?: readonly string[];
  camera?: { position: [number, number, number]; target: [number, number, number] };
  view?: '3d' | '2d';
  surface?: 'dom';
  grid?: false | { size: number; y?: number };
  notes?: readonly string[];
}

export interface SurfaceContext {
  container: HTMLElement;
  refresh: () => void;
}

export interface ExampleContext extends SurfaceContext {
  scene: THREE.Scene;
  camera: THREE.PerspectiveCamera;
  controls: OrbitControls;
  renderer: THREE.WebGLRenderer;
  onFrame: (callback: () => void) => void;
}

export interface PlanSource {
  getDrawing: () => VectorDrawing;
  getElementById?: (ogid: string) => unknown;
  getChangedDrawing?: (ogids: readonly string[]) => VectorDrawing;
  subscribeInvalidation?: (callback: (ogids: readonly string[]) => void) => () => void;
}

export interface PlanElement {
  ogid: string;
}

export type InputControl =
  | { kind: 'text'; input: 'date' | 'password' }
  | { kind: 'file'; accept: string };

export interface ValueControl {
  key: string;
  label: string;
  group?: string;
  control: OPControl | InputControl;
  get: () => unknown;
  set?: (value: unknown) => void;
  commit?: (value: unknown) => void;
}

export type ActionIcon = 'undo' | 'redo';

export interface ActionControl {
  key: string;
  label: string;
  group?: string;
  icon?: ActionIcon;
  run: () => unknown;
}

export type ControlOverride = ValueControl | ActionControl;

export type Verdict = 'pass' | 'fail';

export interface ReportSection {
  title: string;
  verdict?: Verdict;
  columns?: readonly string[];
  rows?: ReadonlyArray<{ cells: ReadonlyArray<string | number>; verdict?: Verdict }>;
  note?: string;
}

export interface EditChoice {
  label: string;
  target: EditTarget;
}

export interface ExampleResult {
  edit?: EditTarget | (() => EditTarget | undefined);
  targets?: readonly EditChoice[] | (() => readonly EditChoice[]);
  pick?: boolean;
  plan?: WallNetwork | readonly PlanElement[] | PlanSource;
  controls?: false | readonly ControlOverride[] | ((target: EditTarget | undefined) => readonly ControlOverride[]);
  report?: () => readonly ReportSection[];
  onEdit?: (change: EditorChange) => void;
  onChange?: () => void;
  onPlan?: (renderer: PlanRenderer) => void;
  expose?: Record<string, unknown>;
  dispose?: () => void;
}

export interface ExampleModule {
  meta: ExampleMeta;
  setup: (context: ExampleContext) => Promise<ExampleResult> | ExampleResult;
}
```

### examples/src/guides/code/model-a-mass.ts

```ts
import {
  Building,
  irMassToMassLike,
  massToShell,
  type BmMass,
  type BmStorey,
  type BuildingModel,
  type BuildingSession,
} from '@opengeometry/floorkit';

export const storeys: BmStorey[] = [
  { id: 'ground', name: 'Ground floor', elevation: 0, height: 4, usage: 'retail' },
  { id: 'first', name: 'First floor', elevation: 4, height: 3, usage: 'residential' },
  { id: 'second', name: 'Second floor', elevation: 7, height: 3, usage: 'residential' },
];

export const mass: BmMass = {
  id: 'building-mass',
  elevation: 0,
  segments: [
    {
      id: 'podium',
      footprint: {
        outerRing: [
          [0, 0],
          [12, 0],
          [12, 8],
          [0, 8],
        ],
      },
      height: 4,
      use: 'retail',
      storeyIds: ['ground'],
    },
    {
      id: 'tower',
      footprint: {
        outerRing: [
          [2, 1],
          [10, 1],
          [10, 7],
          [2, 7],
        ],
      },
      height: 6,
      use: 'residential',
      storeyIds: ['first', 'second'],
    },
  ],
};

import { massSegmentHeightCommand, type ZoningRuleSet } from '@opengeometry/floorkit';

export const zoning: ZoningRuleSet = {
  id: 'guide-rules',
  rules: [
    { kind: 'ratio-cap', id: 'far', quantity: 'floorAreaRatio', max: 0.35 },
    { kind: 'envelope-plane', id: 'setback', edgeClass: 'all', offsetAtGrade: 3 },
  ],
};

export function setSiteAndMass(session: BuildingSession): void {
  session.apply(
    {
      kind: 'replace-site',
      site: {
        id: 'lot',
        boundary: {
          outerRing: [
            [-4, -4],
            [30, -4],
            [30, 14],
            [-4, 14],
          ],
        },
        zoning,
      },
    },
    session.revision,
  );
  session.apply({ kind: 'replace-mass', buildingId: 'building', mass, storeys }, session.revision);
}

export function grossFloorArea(session: BuildingSession): number | undefined {
  const element = session.currentBuild.elementsById.get('building-mass');
  return element instanceof Building ? element.quantities().grossFloorArea : undefined;
}

export function setTowerHeight(session: BuildingSession, height: number): void {
  const command = massSegmentHeightCommand(session.snapshot(), 'building', 'tower', height);
  if (command) session.apply(command, session.revision);
}

export function makeGroundParking(session: BuildingSession): void {
  session.apply(
    {
      kind: 'replace-storey-settings',
      buildingId: 'building',
      storeyId: 'ground',
      settings: {
        name: 'Ground floor',
        elevation: 0,
        height: 4,
        usage: 'parking',
        areaKind: 'parking',
      },
    },
    session.revision,
  );
}

import { massSegmentColorCommand } from '@opengeometry/floorkit';

export function paintTower(session: BuildingSession, color: string | undefined): void {
  const command = massSegmentColorCommand(session.snapshot(), 'building', 'tower', color);
  if (command) session.apply(command, session.revision);
}

import type { ProjectManagerOptions } from '@opengeometry/floorkit';

export const myColours: ProjectManagerOptions = {
  build: { massColors: { residential: '#f2d675', office: '#e58f7b', park: '#7fb069' } },
};

import { massBuildingCommand } from '@opengeometry/floorkit';

export function addBuildingFromOutline(session: BuildingSession): void {
  const command = massBuildingCommand(
    session.snapshot(),
    [
      [16, 0],
      [24, 0],
      [24, 8],
      [16, 8],
    ],
    { storeyHeight: 3, storeyCount: 4, use: 'residential' },
  );
  session.apply(command, session.revision);
}

import type { ManagedPlanEditor, ProjectManager } from '@opengeometry/floorkit';

export async function editMassInPlan(
  manager: ProjectManager,
  container: HTMLElement,
  viewId: string,
): Promise<ManagedPlanEditor | undefined> {
  const view = manager.snapshot().views.find((item) => item.id === viewId);
  if (view?.kind !== 'plan') return undefined;
  manager.apply({
    kind: 'replace-project-item',
    list: 'views',
    id: view.id,
    item: { ...view, showMassing: true },
  });
  const plan = await manager.mountPlanEditor(container, viewId);
  if (plan.renderer) plan.renderer.onDrag = (change) => console.log('live', change.kind);
  return plan;
}

export async function drawBuilding(plan: ManagedPlanEditor): Promise<string | undefined> {
  return plan.startMassDrawing({ storeyHeight: 3, storeyCount: 4, use: 'residential' });
}

import { evaluateZoning, type ZoningReport } from '@opengeometry/floorkit';

export function checkZoning(session: BuildingSession): ZoningReport | undefined {
  const { site, buildings } = session.snapshot();
  if (!site?.boundary || !site.zoning) return undefined;
  const masses = buildings.flatMap((building) =>
    building.mass ? [irMassToMassLike(building)] : [],
  );
  const lot = { profile: site.boundary, lotLines: site.lotLines, elevation: site.elevation };
  return evaluateZoning(lot, masses, site.zoning);
}

export function setSetback(session: BuildingSession, metres: number): void {
  const site = session.snapshot().site;
  if (!site?.zoning) throw new Error('The model has no site with zoning rules.');
  const rules = site.zoning.rules.map((rule) =>
    rule.kind === 'envelope-plane' ? { ...rule, offsetAtGrade: metres } : rule,
  );
  session.apply(
    { kind: 'replace-site', site: { ...site, zoning: { ...site.zoning, rules } } },
    session.revision,
  );
}

export function massToElements(model: BuildingModel): void {
  const building = model.buildings[0];
  if (!building) throw new Error('The model has no building.');
  model.buildings[0] = massToShell(irMassToMassLike(building), { wallThickness: 0.3 });
}
```

### examples/src/guides/code/start-a-building.ts

```ts
import * as THREE from 'three';
import { BuildingSession, type BuildingModel } from '@opengeometry/floorkit';

export const emptyBuilding: BuildingModel = {
  schema: 'floorkit.building-model',
  version: 3,
  units: 'meters',
  upAxis: 'y',
  id: 'my-building',
  buildings: [
    {
      id: 'building',
      storeys: [{ id: 'ground', name: 'Ground floor', elevation: 0, height: 3 }],
    },
  ],
};

export function startBuilding(scene: THREE.Scene): BuildingSession {
  const session = new BuildingSession(emptyBuilding);
  scene.add(session.root);
  return session;
}

import type { ElementLists } from '@opengeometry/floorkit';

export function removeElement(
  session: BuildingSession,
  list: keyof ElementLists,
  elementId: string,
): void {
  session.apply(
    { kind: 'remove-element', buildingId: 'building', storeyId: 'ground', list, elementId },
    session.revision,
  );
}

export function replaceBuilding(
  scene: THREE.Scene,
  current: BuildingSession,
  change: (model: BuildingModel) => void,
): BuildingSession {
  const model = current.snapshot();
  change(model);
  const next = new BuildingSession(model);
  scene.remove(current.root);
  current.dispose();
  scene.add(next.root);
  return next;
}

export function closeBuilding(scene: THREE.Scene, session: BuildingSession): void {
  scene.remove(session.root);
  session.dispose();
}
```

### examples/src/guides/support.ts

```ts
import type * as THREE from 'three';
import {
  drawProjectView,
  type BmStorey,
  type BuildingSession,
  type PaperScreenRenderer,
  type ProjectFile,
} from '@opengeometry/floorkit';
import type { ActionControl, ActionIcon, PlanSource } from '../app/lib/example';
import { addDoor } from './code/add-a-door';
import { addFloorSlab } from './code/add-a-floor-slab';
import { addRoof } from './code/add-a-roof';
import { addWindow } from './code/add-a-window';
import { createProject } from './code/create-a-plan-view';
import { addSectionMark, addSectionViews } from './code/cut-a-section';
import { drawRoom } from './code/draw-a-wall';
import { startBuilding } from './code/start-a-building';

export function groundOf(session: BuildingSession): BmStorey | undefined {
  return session.snapshot().buildings[0]?.storeys.find((storey) => storey.id === 'ground');
}

export function hasWall(session: BuildingSession, wallId: string): boolean {
  return groundOf(session)?.walls?.some((wall) => wall.id === wallId) ?? false;
}

export function hasOpening(session: BuildingSession, wallId: string, openingId: string): boolean {
  return (
    groundOf(session)
      ?.walls?.find((wall) => wall.id === wallId)
      ?.openings?.some((opening) => opening.id === openingId) ?? false
  );
}

export function planOf(session: () => BuildingSession): PlanSource {
  return { getDrawing: () => drawProjectView(createProject(session().snapshot()), 'ground-plan') };
}

export function action(
  key: string,
  label: string,
  run: () => unknown,
  icon?: ActionIcon,
): ActionControl {
  return icon ? { key, label, icon, run } : { key, label, run };
}

export function buildHouse(scene: THREE.Scene): BuildingSession {
  const session = startBuilding(scene);
  drawRoom(session);
  addDoor(session);
  addWindow(session);
  addFloorSlab(session);
  addRoof(session);
  return session;
}

export function houseDrawings(session: BuildingSession): ProjectFile {
  return addSectionMark(addSectionViews(createProject(session.snapshot())));
}

export function fitSheet(renderer: PaperScreenRenderer, container: HTMLElement): () => void {
  const sizes = new ResizeObserver(() => {
    renderer.resize(container.clientWidth, container.clientHeight);
    renderer.fitToPage();
  });
  sizes.observe(container);
  return () => sizes.disconnect();
}
```

### examples/src/guides/code/add-a-door.ts

```ts
import type { BuildingSession } from '@opengeometry/floorkit';

export function addDoor(session: BuildingSession): void {
  session.apply(
    {
      kind: 'add-hosted-opening',
      buildingId: 'building',
      storeyId: 'ground',
      wallId: 'room-walls',
      opening: {
        id: 'entry-door',
        kind: 'door',
        offset: 2,
        width: 0.9,
        height: 2.1,
        doorQuadrant: 1,
      },
    },
    session.revision,
  );
}

export function moveDoor(session: BuildingSession): void {
  session.apply(
    {
      kind: 'move-hosted-opening',
      buildingId: 'building',
      storeyId: 'ground',
      wallId: 'room-walls',
      openingId: 'entry-door',
      offset: 3,
    },
    session.revision,
  );
}

export function removeDoor(session: BuildingSession): void {
  session.apply(
    {
      kind: 'remove-hosted-opening',
      buildingId: 'building',
      storeyId: 'ground',
      wallId: 'room-walls',
      openingId: 'entry-door',
    },
    session.revision,
  );
}

export function moveDoorToWall(session: BuildingSession): void {
  session.apply(
    {
      kind: 'rehost-hosted-opening',
      buildingId: 'building',
      storeyId: 'ground',
      wallId: 'room-walls',
      openingId: 'entry-door',
      toWallId: 'garden-wall',
      offset: 3,
    },
    session.revision,
  );
}
```

### examples/src/guides/code/add-a-floor-slab.ts

```ts
import type { BmSlab, BuildingSession } from '@opengeometry/floorkit';

export const groundFloor: BmSlab = {
  id: 'ground-floor',
  role: 'slab',
  profile: {
    outerRing: [
      [-0.1, -0.1],
      [6.1, -0.1],
      [6.1, 4.1],
      [-0.1, 4.1],
    ],
  },
  thickness: 0.2,
  baseOffset: -0.2,
};

export function addFloorSlab(session: BuildingSession): void {
  session.apply(
    {
      kind: 'add-element',
      buildingId: 'building',
      storeyId: 'ground',
      list: 'slabs',
      element: groundFloor,
    },
    session.revision,
  );
}

export function moveSlab(session: BuildingSession): void {
  session.apply(
    {
      kind: 'transform-slab',
      buildingId: 'building',
      storeyId: 'ground',
      slabId: 'ground-floor',
      origin: [0, 0],
      translation: [1, 0],
      yawDeg: 0,
    },
    session.revision,
  );
}
```

### examples/src/guides/code/add-a-roof.ts

```ts
import { RoofType, type BmRoof, type BuildingSession } from '@opengeometry/floorkit';

export const mainRoof: BmRoof = {
  id: 'main-roof',
  profile: {
    outerRing: [
      [-0.1, -0.1],
      [6.1, -0.1],
      [6.1, 4.1],
      [-0.1, 4.1],
    ],
  },
  roofType: RoofType.GABLE,
  pitch: 30,
  overhang: 0.3,
  thickness: 0.2,
  baseOffset: 3,
};

export function addRoof(session: BuildingSession): void {
  session.apply(
    {
      kind: 'add-element',
      buildingId: 'building',
      storeyId: 'ground',
      list: 'roofs',
      element: mainRoof,
    },
    session.revision,
  );
}
```

### examples/src/guides/code/add-a-window.ts

```ts
import type { BuildingSession } from '@opengeometry/floorkit';

export function addWindow(session: BuildingSession): void {
  session.apply(
    {
      kind: 'add-hosted-opening',
      buildingId: 'building',
      storeyId: 'ground',
      wallId: 'room-walls',
      opening: {
        id: 'south-window',
        kind: 'window',
        offset: 4.5,
        width: 1.2,
        height: 1.2,
        sillHeight: 0.9,
      },
    },
    session.revision,
  );
}
```

### examples/src/guides/code/create-a-plan-view.ts

```ts
import {
  drawProjectView,
  type BuildingModel,
  type ProjectFile,
  type SavedDrawingView,
} from '@opengeometry/floorkit';

export const groundPlan: SavedDrawingView = {
  id: 'ground-plan',
  name: 'Ground floor plan',
  kind: 'plan',
  storeyId: 'ground',
  cutHeightAboveStorey: 1.2,
};

export function createProject(buildingModel: BuildingModel): ProjectFile {
  return {
    schema: 'floorkit.project',
    version: 3,
    buildingModel,
    views: [groundPlan],
    paper: { version: 1, unitSystem: 'metric', sheets: [] },
  };
}

export function planLinework(project: ProjectFile) {
  const drawing = drawProjectView(project, 'ground-plan');
  console.log(`${drawing.primitives.length} primitives`, drawing.bounds);
  return drawing;
}
```

### examples/src/guides/code/cut-a-section.ts

```ts
import type { ProjectFile, SavedDrawingView } from '@opengeometry/floorkit';

export const sectionA: SavedDrawingView = {
  id: 'section-a',
  name: 'Section A',
  kind: 'section',
  line: { start: [4.5, -1], end: [4.5, 5] },
  lookSide: 'right',
  depth: 5,
  verticalCrop: { minY: -0.5, maxY: 6 },
  settings: { levelLabels: { datum: 0, unit: 'm' } },
};

export const southElevation: SavedDrawingView = {
  id: 'elevation-south',
  name: 'South elevation',
  kind: 'elevation',
  origin: [3, -2],
  lookDirection: [0, 1],
  crop: { left: -4, right: 4, bottom: -6, top: 0.5 },
};

export function addSectionViews(project: ProjectFile): ProjectFile {
  return { ...project, version: 3, views: [...project.views, sectionA, southElevation] };
}

import type { ProjectViewMarker } from '@opengeometry/floorkit';

export const sectionMark: ProjectViewMarker = {
  id: 'section-a-mark',
  kind: 'source',
  parentViewId: 'ground-plan',
  target: { viewId: 'section-a' },
};

export function addSectionMark(project: ProjectFile): ProjectFile {
  return { ...project, version: 3, markers: [...(project.markers ?? []), sectionMark] };
}
```

### examples/src/guides/code/draw-a-wall.ts

```ts
import type { BuildingSession } from '@opengeometry/floorkit';

export function drawWall(session: BuildingSession): void {
  session.apply(
    {
      kind: 'create-wall',
      buildingId: 'building',
      storeyId: 'ground',
      wall: {
        id: 'garden-wall',
        points: [
          [0, 7],
          [6, 7],
        ],
        thickness: 0.2,
      },
    },
    session.revision,
  );
}

export function drawRoom(session: BuildingSession): void {
  session.apply(
    {
      kind: 'create-wall',
      buildingId: 'building',
      storeyId: 'ground',
      wall: {
        id: 'room-walls',
        points: [
          [0, 0],
          [6, 0],
          [6, 4],
          [0, 4],
        ],
        closed: true,
        thickness: 0.2,
      },
    },
    session.revision,
  );
}

export function removeWall(session: BuildingSession): void {
  session.apply(
    { kind: 'delete-wall', buildingId: 'building', storeyId: 'ground', wallId: 'garden-wall' },
    session.revision,
  );
}
```
