# Add a 3D editor

> Click a wall in the 3D view, then drag an end dot to pull that end off its corner or the middle dot to move the wall. The walls follow the pointer, and each drag is one step that you can undo.

- Live example: https://floorkit-examples.ploterr.com/floorkit/guides/add-a-3d-editor
- Docs page: https://docs.ploterr.com/floorkit/guides/add-a-3d-editor
- Category: Edit
- API: `ProjectManager`, `GUIEditor`
- Tags: editor, 3d, handles
- Package: `@opengeometry/floorkit`
- Source: `examples/src/guides/add-a-3d-editor.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/add-a-3d-editor.example.ts

```ts
import type { ExampleContext, ExampleMeta, ExampleResult } from '../app/lib/example';
import {
  closeSceneEditor,
  mountSceneEditor,
  openSceneProject,
  sceneActions,
} from './code/add-a-3d-editor';
import { addDoor } from './code/add-a-door';
import { addWindow } from './code/add-a-window';
import { createProject } from './code/create-a-plan-view';
import { drawRoom } from './code/draw-a-wall';
import { emptyBuilding } from './code/start-a-building';
import { action, planOf } from './support';

export const meta: ExampleMeta = {
  title: 'Add a 3D editor',
  description:
    'Click a wall in the 3D view, then drag an end dot to pull that end off its corner or the middle dot to move the wall. The walls follow the pointer, and each drag is one step that you can undo.',
  tags: ['editor', '3d', 'handles'],
  api: ['ProjectManager', 'GUIEditor'],
  camera: { position: [12, 8, 11], target: [3, 1.5, 2] },
};

export function setup({
  scene,
  camera,
  controls,
  container,
  onFrame,
}: ExampleContext): ExampleResult {
  const manager = openSceneProject(scene, createProject(emptyBuilding));
  drawRoom(manager.session);
  addDoor(manager.session);
  addWindow(manager.session);
  const editor = mountSceneEditor(container, camera, controls, manager);
  onFrame(() => editor.update());
  const history = sceneActions(manager);
  return {
    plan: planOf(() => manager.session),
    controls: [
      action('undo', 'Undo', history.undo, 'undo'),
      action('redo', 'Redo', history.redo, 'redo'),
    ],
    expose: { session: manager.session, sceneEditor: editor, manager },
    dispose: () => closeSceneEditor(scene, manager, editor),
  };
}
```

### 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/add-a-3d-editor.ts

```ts
import type * as THREE from 'three';
import {
  GUIEditor,
  ProjectManager,
  type CameraControlsLike,
  type ProjectFile,
} from '@opengeometry/floorkit';

export function openSceneProject(scene: THREE.Scene, project: ProjectFile): ProjectManager {
  const manager = new ProjectManager(project, {
    onError: (error) => console.error('Floorkit rejected the edit', error),
  });
  scene.add(manager.root);
  return manager;
}

export function mountSceneEditor(
  container: HTMLElement,
  camera: THREE.Camera,
  controls: CameraControlsLike,
  manager: ProjectManager,
): GUIEditor {
  const editor = new GUIEditor(container, camera, controls);
  manager.bindSceneEditor(editor);
  return editor;
}

export function renderFrame(
  editor: GUIEditor,
  renderer: THREE.WebGLRenderer,
  scene: THREE.Scene,
  camera: THREE.Camera,
): void {
  editor.update();
  renderer.render(scene, camera);
}

export function sceneActions(manager: ProjectManager) {
  return {
    undo: () => manager.undo(),
    redo: () => manager.redo(),
  };
}

export function closeSceneEditor(
  scene: THREE.Scene,
  manager: ProjectManager,
  editor: GUIEditor,
): void {
  editor.dispose();
  scene.remove(manager.root);
  manager.dispose();
}
```

### 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-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/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,
  );
}
```

### 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-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/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] };
}
```
