# Spatial assembly

Spatial assembly adds reusable plans, connection points, snapping, collision
rules, clearances, adjacency validation, bills of materials, quotes and
production exports to any Product 3D catalog.

The feature is domain-neutral. A component can represent a cabinet, machine,
exhibition panel, light, rack, pipe, vehicle option or any other spatial
object.

## The mental model

Assembly does not infer a kitchen or a room from a product model. The built-in
builder lets an administrator draw that space with the mouse, while the same
surfaces can be created through the API. Assembly then applies the same four
concepts to any catalog:

1. **Plan**: the floor outline and optional walls. It defines where components
   are allowed to exist.
2. **Component**: one instance of a Product 3D element already present in the
   catalog.
3. **Connection point**: a local point on that component, comparable to a
   magnet. Two compatible points can align and create a connection.
4. **Rule**: a constraint checked before accepting placement, such as
   collision, required clearance, adjacency or plan boundaries.

For a cabinet configurator, cabinet GLBs are the components and their side
edges are connection points. For an exhibition stand, panels and counters are
components. For a production line, machines and conveyors are components.
The runtime is identical; only the catalog recipes change.

The demo catalog intentionally uses a simple modular GLB. Its purpose is to
make the mechanics visible: add a unit, attach another one, move it, reject an
invalid overlap and produce a BOM. A scene comparable to a kitchen planner
requires a matching catalog of cabinet, appliance and corner GLBs; Assembly is
the general engine that arranges and validates those assets.

## 1. Describe reusable components

Assembly behavior belongs to the Product 3D element definition. Positions use
the same local world unit as the GLB model. Rotations exposed by the API use
radians.

```ts
const machineModule = {
  id: 'machine-module',
  name: 'Machine module',
  type: 'product-3d',
  product3d: {
    model: { url: '/models/machine-module.glb', format: 'glb' },
    parts: [],
  },
  assembly: {
    schemaVersion: 1,
    enabled: true,
    category: 'machine-module',
    tags: ['floor-mounted', 'connectable'],
    placement: {
      // Align the lowest collision point with plan.floorY.
      // Use `free` for suspended, wall-mounted or unconstrained components.
      support: 'floor',
      supportOffset: 0,
    },

    collision: {
      enabled: true,
      margin: 0.03,
      group: 'equipment',
      collidesWith: ['equipment'],
    },

    anchors: [
      {
        id: 'input',
        label: 'Input',
        role: 'target',
        position: { x: -0.8, y: 0.5, z: 0 },
        normal: { x: -1, y: 0, z: 0 },
        accepts: ['production-line'],
        snapDistance: 0.3,
        alignRotation: true,
      },
      {
        id: 'output',
        label: 'Output',
        role: 'source',
        position: { x: 0.8, y: 0.5, z: 0 },
        normal: { x: 1, y: 0, z: 0 },
        provides: ['production-line'],
        snapDistance: 0.3,
        alignRotation: true,
      },
    ],

    clearances: [
      {
        id: 'service-access',
        label: 'Service access',
        center: { x: 0, y: 1, z: -0.9 },
        size: { x: 1.4, y: 2, z: 0.8 },
        severity: 'error',
        message: 'Keep the service access area clear.',
      },
    ],

    adjacency: [
      {
        id: 'requires-output',
        type: 'requires-connection',
        target: { connectorTypes: ['production-line'] },
        severity: 'warning',
        message: 'Connect this module to the production line.',
      },
    ],

    bom: [
      {
        id: 'main-unit',
        sku: 'MACHINE-100',
        label: 'Machine module',
        quantity: 1,
        unit: 'piece',
        unitPrice: 2490,
        category: 'equipment',
      },
      {
        id: 'mounting-kit',
        sku: 'MOUNT-4',
        label: 'Mounting kit',
        quantity: 4,
        unit: 'piece',
        unitPrice: 12.5,
        category: 'hardware',
      },
    ],
  },
};
```

The Elements editor exposes these fields visually. Integrators do not need to
edit JSON to configure anchors, collision groups, clearances, adjacency rules
or BOM lines.

## 2. Draw floors and walls

In a 3D view, open **Assembly**, choose **Enter construction mode**, then use
**Build the space**:

1. Choose **Draw room**, then drag a rectangle in the 3D view. This creates one
   floor and four walls.
2. Choose **Custom room** to click any simple, concave or convex outline. Press
   `Enter` or click the first corner to close it. **Custom floor** creates the
   same free outline without linked walls.
3. Use **Draw floor** to add another usable floor area.
4. Use **Draw wall** to drag an individual wall between two points. Walls may
   follow any angle; hold `Shift` while drawing to use a 15-degree guide.
5. Use **Select surface** to choose a floor or wall. Teal, purple, blue and
   light-blue handles move, rotate, reshape and resize that surface directly
   in the scene. Exact values remain available in the panel.
6. Use **Erase surface** to remove a surface, or **Back to move** to arrange
   components again.

The grid snaps dimensions while drawing. Hand-drawn floors are also the real
placement boundary: components cannot be dropped into an empty gap between two
separate floor surfaces.

Selecting a floor exposes its area, perimeter, exact corner coordinates, edge
lengths and edge angles. Measurements can be displayed in m, cm or mm. Each
edge length and angle can be locked independently. A linked room also exposes
one wall toggle per edge. An intentionally open edge can be closed again
without losing the wall id, height, thickness, or interior/exterior finishes.
Inserting or removing a corner preserves finishes on every unchanged stable
edge. Adjacent walls are joined with mitered footprints, including on arbitrary
polygon angles, so the structural and finish meshes do not leave corner gaps
or overlap one another.

Deleting a room floor displays the number of linked walls that will also be
removed and requires a second confirmation. The complete removal remains one
history operation and can be restored with Undo.

Created floors and walls are physical scene surfaces and remain visible. The
**Show grid and construction guides** checkbox controls only the floor and wall
grids plus technical safety overlays. Hiding the guides does not disable grid
snapping, floor support, plan boundaries, collision prevention or the
last-valid-position fallback. Guides are hidden by default, and entering
construction mode never changes that preference.

When no explicit floor or wall has been created yet, construction mode starts
with an empty workspace instead of displaying the legacy generated room. Saved
surfaces are never cleared when construction mode is reopened.

The same workflow is available through the public API:

```ts
designer.assembly.setConstructionMode(true);

designer.assembly.startDrawing({
  tool: 'room',
  replace: true,
  onCreate: (surfaces) => console.log('Created', surfaces),
});

// The user drags in the active Babylon canvas, then:
designer.assembly.cancelDrawing();
designer.assembly.setMoveTool(true);

const floor = designer.assembly
  .listSurfaces()
  .find((surface) => surface.kind === 'floor');

if (floor) {
  const metrics = designer.assembly.getSurfaceMetrics(floor.id);
  console.log(metrics?.area, metrics?.perimeter, metrics?.edges);

  // Set the first edge to 3.2 world units while keeping its direction.
  await designer.assembly.setFloorEdgeLength(floor.id, 0, 3.2);
  await designer.assembly.setFloorEdgeAngle(floor.id, 0, 30);
  await designer.assembly.setFloorEdgeConstraint(floor.id, 0, {
    lockLength: true,
    lockAngle: true,
  });

  // Move and rotate the complete floor without changing its dimensions.
  await designer.assembly.transformSurface(floor.id, {
    center: { x: 2, z: -1 },
    rotationDegrees: 45,
  });

  // Create an opening, then restore the same wall and its finishes.
  await designer.assembly.setRoomWallEnabled(floor.id, 0, false);
  await designer.assembly.setRoomWallEnabled(floor.id, 0, true);

  await designer.assembly.updateSurface(floor.id, {
    material: {
      color: '#d9dee7',
      textureUrl: '/textures/light-oak.webp',
      // One texture tile represents 60 x 60 cm when one world unit is one meter.
      textureSize: { width: 0.6, height: 0.6 },
      textureRotation: 90,
    },
  });

  const material = await designer.assembly.saveMaterialPreset({
    name: 'Client oak',
    category: 'Wood',
    material: {
      color: '#d9b98c',
      textureUrl: '/textures/client-oak.webp',
      textureSize: { width: 0.8, height: 0.8 },
    },
  });
  if (material) {
    await designer.assembly.applyMaterialPreset(floor.id, material.id, {
      scope: 'room',
    });
  }
}

designer.assembly.setConstructionMode(false);
```

Available tools are `select`, `room`, `floor`, `polygon-room`,
`polygon-floor`, `wall` and `erase`.
`getBuildState()`, `selectSurface()`, `removeSurface()` and `clearSurfaces()`
allow a host application to build its own controls around the same renderer
behavior.

### Precise editing and reusable finishes

The selected surface exposes four scene controls:

- blue spheres move individual corners or wall endpoints;
- light-blue squares move a complete floor edge;
- the teal center handle moves the complete surface;
- the purple ring rotates the complete surface using the configured angle step.

Holding `Shift` while rotating temporarily disables angle snapping. Numeric
fields and scene handles call the same renderer API, so history, linked walls
and stable edge identities behave the same in custom interfaces.

The material library contains built-in finishes and project-specific saved
finishes. A preset can target the selected wall face, both wall faces, one
floor, or the complete linked room. Texture repetition can be expressed either
as a repeat count or as a real tile width and height. The second mode keeps
planks, tiles and patterns at a consistent physical size on differently sized
surfaces. The library can also be exported to JSON and imported into another
project from the module without rebuilding each finish manually.

## 3. Configure a plan

Open the built-in **Assembly** module in a 3D view, or use the public API:

```ts
const result = await designer.assembly.configure(
  {
    enabled: true,
    plan: {
      // X/Z outline in world units.
      points: [
        { x: -4, z: -3 },
        { x: 4, z: -3 },
        { x: 4, z: 3 },
        { x: -4, z: 3 },
      ],
      floorY: 0,
      height: 2.8,
      wallThickness: 0.08,
      closed: true,
    },
    settings: {
      grid: { enabled: true, size: 0.1, subdivisions: 5 },
      snap: {
        enabled: true,
        anchors: true,
        boundaries: true,
        grid: true,
        rotation: true,
        autoConnect: true,
        distance: 0.3,
        rotationStepDegrees: 10,
      },
      measurement: {
        displayUnit: 'cm',
        // One Babylon world unit represents one physical meter.
        worldUnitInMeters: 1,
      },
      collision: {
        enabled: true,
        policy: 'prevent',
        margin: 0.02,
        includeClearances: true,
      },
      adjacency: {
        enabled: true,
        autoCorners: true,
      },
    },
  },
  { source: 'host.configure-room', commitHistory: true },
);

console.log(result.value);
```

The Assembly module also provides rectangle, L-shaped and U-shaped generated
presets. Choosing a preset replaces hand-drawn surfaces. A host application
can pass any polygon or explicit floor and wall collection through the API.

## Guided editor

The built-in module is split into three simple steps:

1. **Space** draws floors and walls. Left-drag builds, while the mouse wheel
   and right-drag continue to control the camera.
2. **Place** places and rotates reusable components.
3. **Check** presents validation, the BOM and production exports.

Enter construction mode once, then drag any component normally to move it over
the floor. Hold `Ctrl` before starting the drag to move it vertically instead;
its horizontal position remains fixed and the height follows the wall grid.
Snapping keeps both gestures aligned with the configured grid. Right-drag or use
the wheel to adjust the camera without leaving construction mode. Closing the
Assembly panel does not stop the session: reopen it when needed and use **Exit
construction mode** to finish.

The same placement tool is exposed to host applications:

```ts
designer.assembly.setConstructionMode(true);

console.log(designer.assembly.getInteractionState());
// { constructionModeActive: true, moveToolActive: true }

designer.assembly.setConstructionMode(false);
```

Outside construction mode, `Ctrl + drag` remains available as a quick temporary
placement gesture. In construction mode it selects the vertical movement axis.
Hold `Shift` during a move for precise placement without snapping. Press `Q` or
`E`, or use the two rotation buttons, to rotate the selected component. The
default step is 10 degrees and can be changed in the advanced settings.

When the pointer leaves the allowed area, the live preview stops at the last
valid position. Releasing the pointer commits that valid position instead of
letting the object escape the floor or jumping it back to its initial location.

Optional technical overlays are hidden by default so the scene remains
readable. Enable only the guide needed to inspect a configured rule:

- teal handles are available connection points;
- amber handles are compatible targets on neighbouring components;
- blue handles and lines are established connections;
- teal outlines are occupied collision volumes;
- orange outlines are reserved clearance zones;
- red volumes identify a collision or clearance violation.

The helpers are not pickable and never appear in image, BOM or production
exports. Detailed volumes are shown only for the selected component by
default, while compatible targets remain visible on its neighbours.

```ts
await designer.assembly.configure({
  settings: {
    visualization: {
      enabled: true,
      anchors: true,
      collisions: true,
      clearances: true,
      connections: true,
      dimensions: true,
      selectedOnly: true,
    },
  },
});
```

When dimensions are enabled, selecting a floor displays each edge length in the
scene. Selecting a wall displays its length and height. Labels follow the
configured m, cm or mm display unit and remain billboards as the camera moves.

For a quick hands-on example, open the Assembly module with an assembly-ready
Product 3D element in the catalog and choose **Build and frame example**. The
module creates a U-shaped work area, places the first unit on the floor, adds a
second unit, connects their free side anchors, centers the camera and exposes
the resulting BOM. The generated mutations are recorded in history and can be
undone.

## Editing permissions

Assembly can be inspected in every runtime, but scene mutations have different
defaults:

- `mode: 'dev'` enables Assembly editing for trusted authoring interfaces;
- `mode: 'prod'` keeps Assembly visible but read-only;
- a production integration must explicitly grant `canEditAssembly` to expose
  floor, wall, placement, rotation and connection controls.

```ts
const designer = createAsukaDesigner('#designer', {
  mode: 'prod',
  permissions: {
    canViewAssembly: true,
    canEditAssembly: true,
  },
});
```

Keep that override in a trusted administration experience. Customer-facing
product pages should retain the production default unless their workflow
explicitly requires spatial editing. Calls to protected public or renderer
Assembly mutations throw `AsukaRendererError` with code
`UNSUPPORTED_OPERATION` and identify `canEditAssembly` as the denied
permission.

## Parametric resizing inside an assembly

Resizing a connected component preserves three independent invariants:

1. the edited component keeps its X/Z center and its configured support
   contact, so a floor-supported object does not jump or sink;
2. connected neighbours are moved so established connection points still
   coincide;
3. the plan grows only when the resulting assembly no longer fits.

The collision envelope is always rebuilt from the real rendered bounds. Its
teal outline means "the component physically occupies this space." A clearance
is a separate reserved volume, shown with an orange outline: it can represent a
door swing, maintenance access, ventilation or any other space that must remain
empty. Neither box is product geometry, and both stay hidden until their
technical guide is enabled.

Anchors and clearances are fixed in local model units by default. Mark the
coordinates that represent a moving edge as resize-aware:

```ts
assembly: {
  anchors: [
    {
      id: 'right',
      // Normalized live bounds: right face, lower face, depth center.
      position: { x: 0.5, y: -0.5, z: 0 },
      positionMode: 'bounds',
      normal: { x: 1, y: 0, z: 0 },
      provides: ['modular-side'],
    },
  ],
  clearances: [
    {
      id: 'front-access',
      center: { x: 0, y: 0.6, z: -0.9 },
      size: { x: 1.4, y: 1.2, z: 0.8 },
      positionMode: 'scaled',
      // Add `sizeMode: 'scaled'` only when the reserved space itself must grow.
      sizeMode: 'fixed',
    },
  ],
}
```

`bounds` uses normalized coordinates from `-0.5` to `0.5`; it is particularly
useful for floor-supported modules because the connection can stay on the
lower face while width, height and depth change. Use `scaled` when the anchor
must follow a local authored coordinate instead.

Use a fixed clearance size for a physical requirement such as 80 cm of service
access. Use a scaled size when the reserved volume is part of the component's
parametric geometry.

The corresponding assembly behavior is controlled independently:

```ts
await designer.assembly.configure({
  plan: {
    autoExpand: true,
    autoExpandPadding: 0.2,
  },
  settings: {
    adjacency: {
      reflowOnResize: true,
    },
  },
});
```

Both options are enabled by default and can be changed in the Assembly module
with **Expand the plan when connected components grow** and **Keep connected
components aligned after resizing**.

## 4. Place and connect components

Every placement is first evaluated by the pure assembly engine. Invalid
positions can be rejected while the previous valid transform is preserved.

```ts
const components = designer.assembly.listComponents();
const component = components[0];

const preview = designer.assembly.previewPlacement(component.id, {
  position: { x: 1.2, y: 0, z: -0.8 },
  snap: true,
});

if (preview.accepted) {
  await designer.assembly.place(
    component.id,
    {
      position: preview.applied.position,
      support: 'wall',
      snap: true,
    },
    { source: 'host.pointer-drop', commitHistory: true },
  );
}
```

The support can be `free`, `floor`, `wall` or `ceiling`. It is stored per
component instance, so two copies of the same catalog element can use different
supports. Floor and ceiling supports preserve vertical contact. Wall support
chooses the nearest wall and keeps the component against its interior face
during later moves. The module exposes the same choice for the selected
component.

While dragging, a wireframe placement ghost shows the proposed footprint:
green means the position can be committed, red means a boundary, collision or
clearance rule rejects it. The last valid position remains available when the
pointer temporarily crosses an invalid area.

Automatic placement first chooses the closest compatible free anchor. If none
is available, it searches the active floor from its centre outwards and places
the component at the first collision-free position inside the drawn area. A
new catalog component therefore enters the room instead of appearing beside
it:

```ts
await designer.assembly.autoPlace(component.id, {
  source: 'host.auto-place',
  commitHistory: true,
});
```

Connections can also be explicit:

```ts
await designer.assembly.connect({
  source: { componentId: 'module-a', anchorId: 'output' },
  target: { componentId: 'module-b', anchorId: 'input' },
  connectorType: 'production-line',
});
```

When two compatible components of the same height approach a corner, Assembly
can join their closest anchors without forcing a right angle. Each component
keeps its chosen rotation, so the same rule works for straight, acute and obtuse
room corners. The resulting connection is included in validation, history, BOM
and production exports.

Vertical stack links cover components placed above one another, such as a wall
cabinet above a base cabinet:

```ts
await designer.assembly.linkStack({
  lowerComponentId: 'base-cabinet',
  upperComponentId: 'wall-cabinet',
  widthAxis: 'auto',
  syncWidth: true,
});
```

The upper component is centered on the lower component while its existing
vertical spacing is preserved. Pass an explicit `gap` only when a fixed distance
is required; `gap: 0` deliberately places both bounding boxes in contact. Its
width follows the lower component when space is reduced, but never grows beyond
the upper component width recorded when the link was created. Links can form a
chain, so resizing the bottom component reflows every level above it without
cumulative drift. The complete relation is persisted in history and exported
state.

```ts
const links = designer.assembly.listStackLinks();
await designer.assembly.unlinkStack(links[0].id);
```

## 5. Validate the complete scene

```ts
const validation = designer.assembly.validate();

for (const issue of validation.issues) {
  console.log(issue.severity, issue.code, issue.message, issue.componentIds);
}
```

Validation covers:

- product collisions and configurable collision groups;
- reserved clearance volumes;
- plan boundaries;
- required and forbidden neighbors;
- minimum and maximum gaps;
- required anchor connections;
- connector compatibility and anchor capacity.

## 6. BOM, quote and production export

```ts
const bom = designer.assembly.getBom({ currency: 'EUR' });

const quote = designer.assembly.getQuote({
  currency: 'EUR',
  adjustments: [
    { id: 'installation', label: 'Installation', amount: 350 },
  ],
  metadata: { projectId: 'PROJECT-42' },
});

const csv = await designer.assembly.export('bom-csv', {
  currency: 'EUR',
});

const productionPackage = await designer.assembly.export('production-json', {
  currency: 'EUR',
  metadata: { projectId: 'PROJECT-42' },
});
```

`production-json` contains the plan, settings, component transforms,
connections, validation result, BOM and quote. It is suitable as the canonical
handoff payload for ERP, manufacturing, installation or custom server
workflows.

## Renderer boundary

The assembly API requires the licensed `assembly` add-on and is available only
while a 3D view is active and the Babylon renderer is loaded. Babylon itself is
available without a commercial license. Calling the assembly API from a Fabric
view throws an `AsukaRendererError` with code `UNSUPPORTED_OPERATION`.

All values crossing `designer.assembly` are serializable. Babylon meshes,
materials, vectors and observables remain private to the 3D adapter.
