Skip to main content

Deriving form

No form is written in .muro. The position of a wall, the stretches a wall is divided into by its openings, the coordinates of a column, how a stair divides into risers — all of it is generated from rules. derive(model) is the one entrance to it.

import {
canonicalBoundaryOrder, derive, DERIVATION_CONSTANTS, deriveDefaultBoundaries,
levelPitch, placeBand, placeOpening, segmentsFor, TOLERANCES,
} from "@kensnzk/koyu";

derive

function derive(model: Model, opts?: DeriveOptions): Form

interface DeriveOptions {
cut?: number; // height of the plan's cutting plane, mm above FL (default 1200)
}

The arguments are the source and the things that decide form, nothing else. No scale, no margin, no orientation, no colour — those are appearance, not form.

Form carries no appearance at all. What comes back is coordinates, thicknesses, z ranges, orientations and the identity of each subject. No colour, typeface, line weight, annotation string, symbol, scale or page margin appears anywhere in the type. svgPlan and svgAxo merely draw it.

import { derive } from "@kensnzk/koyu";
import { parseFile } from "@kensnzk/koyu/node";

const b = parseFile("examples/basement/main.muro");
const form = derive(b);
console.log(`levels=${form.levels.length} spaces=${form.spaces.length} boundaries=${form.boundaries.length} openings=${form.openings.length} segs=${form.segs.length} slabs=${form.slabs.length} columns=${form.columns.length} runs=${form.runs.length} site=${form.site.length} plans=${form.plans.length}`);
console.log(form.input);
levels=4 spaces=13 boundaries=45 openings=7 segs=0 slabs=29 columns=36 runs=7 site=0 plans=4
{ cut: 1200 }

Form

interface Form {
input: FormInput; // { cut: number }
levels: FormLevel[];
spaces: FormSpace[];
boundaries: FormBoundary[];
openings: FormOpening[];
segs: FormSeg[];
slabs: Slab[];
columns: FormColumn[];
runs: FormRun[];
site: FormSite[];
plans: FormPlan[];
}

input echoes the arguments used. The cut height is an input to the Form, not a part of it — the same model can produce forms at different cut heights.

The indices are canonical order

The boundary index on FormBoundary, FormOpening and FormSeg indexes canonicalBoundaryOrder(model), not the declaration order in model.boundaries.

The reason is simple. Declaration order is information canonical JSON throws away, and information that is thrown away must not change form. Spell identity from declaration order and two models with byte-identical canonical JSON split into a|b@0 and a|b@1.

canonicalBoundaryOrder

function canonicalBoundaryOrder(model: Model): Boundary[]

Returns the boundaries in canonical order: by the collation of each boundary serialised as its canonical entry, ties broken by declaration order for stability. Default (derived) boundaries are ordered by the same rule — they never reach canonical JSON, but they are derived from the order of model.spaces, so without the same reordering they carry the same disease.

import { canonicalBoundaryOrder } from "@kensnzk/koyu";

const order = canonicalBoundaryOrder(b);
for (let i = 0; i < 6; i++) {
const bb = order[i]!;
console.log(`canonical ${i}\tdeclared ${b.boundaries.indexOf(bb)}\t${bb.a} | ${bb.b}`);
}
canonical 0	declared 17	/B1/ev | /B1/mech
canonical 1	declared 9	/B1/ramp | /B1/ev
canonical 2	declared 13	/B1/st | /B1/ev
canonical 3	declared 47	/B2/ev | /B1/ev
canonical 4	declared 48	/B1/ev | /L1/ev
canonical 5	declared 5	/B1/park | /B1/mech

The two orders do not agree. To get from an index inside Form back to the source boundary, index canonicalBoundaryOrder(model), not model.boundaries.

For the same reason, cutting regions by drawn lines happens in this order too. Each cut reads the result of the previous one, so order matters; cutting in declaration order would produce different areas from the same canonical JSON.

FormLevel

interface FormLevel {
name: string;
z: number;
h?: number;
slab?: number;
pitch?: number;
}

pitch is the storey height — how far walls and columns stand.

  • With a level above, the difference is the storey height.
  • With nothing above, it reaches the apex of the roof: the greatest ceiling height on that storey plus the roof slab thickness. Any other formula and walls punch through the roof, or a gap opens under it.
  • If no ceiling height can be settled, the storey height cannot either, and no wall and no column stands on that level. No default is invented (SUF01 already says so as an error).

levelPitch

function levelPitch(model: Model, level: string): number | undefined

The same computation, callable on its own.

import { levelPitch } from "@kensnzk/koyu";
for (const l of form.levels) console.log(l.name, levelPitch(b, l.name));
B2 3700
B1 3700
L1 4900
R undefined
console.log(form.levels);
[
  { name: 'B2', z: -7400, h: 2600, slab: 800, pitch: 3700 },
  { name: 'B1', z: -3700, h: 2600, slab: 800, pitch: 3700 },
  { name: 'L1', z: 0, h: 4000, slab: 900, pitch: 4900 },
  { name: 'R', z: 4900, slab: 500 }
]

The topmost level R has no ceiling height, so it has no pitch — and nothing stands there.

FormSpace

interface FormSpace {
path: string;
type: string;
level?: string;
outline: Pt[][]; // the derived region (convex pieces), counter-clockwise
areaM2?: number;
z0?: number; // the volume — only when a ceiling height is settled
z1?: number;
indoor: boolean;
semiOutdoor: boolean;
covered: boolean; // is a space stacked above it?
}

Spaces with no region (an exterior) do not appear in the Form at all.

console.log(form.spaces[0]);
{
  path: '/B2/park',
  type: 'parking',
  level: 'B2',
  outline: [ [ [Object], [Object], [Object], [Object] ] ],
  areaM2: 256,
  z0: -7400,
  z1: -4800,
  indoor: true,
  semiOutdoor: false,
  covered: true
}

FormBoundary and FormPanel

interface FormBoundary {
ref: string; // identity of the relation — `a|b@i`
boundary: number; // the canonical index i
a: string;
b: string;
kind: BoundaryKind;
derived: boolean;
level?: string;
air: boolean;
segment: Segment; // the centre line
material?: {
t: number;
z0: number;
z1: number;
panels: FormPanel[];
};
}

interface FormPanel {
x1: number; y1: number; x2: number; y2: number;
z0: number; z1: number;
}

A boundary with several segments produces one FormBoundary per segment — same ref, different segment.

material is present only where something is there: kind:"wall" with a level and a storey height settled. An open boundary has a centre line and no material.

A wall arrives already divided by its openings. There is no "paint the black band the colour of the paper" step — the holes are in panels from the start.

const wall = form.boundaries.find((x) => x.material && x.material.panels.length > 1)!;
console.log(wall.ref, wall.a, wall.b, wall.kind, wall.derived, wall.level, wall.air);
console.log(`t=${wall.material!.t} z=${wall.material!.z0}${wall.material!.z1}`);
for (const p of wall.material!.panels) console.log(` panel (${p.x1},${p.y1})-(${p.x2},${p.y2}) z ${p.z0}${p.z1}`);
/B1/park|/B1/mech@5 /B1/park /B1/mech wall false B1 false
t=250 z=-3700→0
  panel (16000,12400)-(16000,13600) z -3700→0
  panel (16000,13600)-(16000,14800) z -1700→0
  panel (16000,14800)-(16000,16000) z -3700→0

The middle of the three stretches is the head panel over the door: its underside (−1700) sits at the head of the door, 2000 mm above the floor at −3700.

The spelling of ref is <path a>|<path b>@<canonical index>. # is avoided because it collides with the spelling of a colour.

FormOpening and FormSwing

interface FormOpening {
ref: string; // `<the boundary's ref>/<opening index>`
boundary: number;
index: number;
a: string; b: string;
kind: "door" | "window";
name?: string;
level?: string;
segment: Segment;
cx: number; cy: number; // centre, mm
w: number;
z0: number; z1: number;
t: number; // the joinery's face thickness = wall thickness
style?: string;
swing?: FormSwing;
sliding: boolean; // style:sliding / style:auto
}

interface FormSwing {
into: string; // the space it opens into
hinge: Pt;
leaf: Pt; // where the leaf ends up fully open
jamb: Pt; // the opposite jamb
ccw: boolean; // does the arc sweep counter-clockwise?
}

The z range follows the kind. A door rises from the floor; everything else hangs down from the head height (2000 mm) by its own height. Sill height (sill) is a carried attribute, so core does not read it — aligning the heads is what fixes the bottom.

swing is present on doors only. Which side it opens into comes from swing:a/b, or else the side that has a region (a first). The direction is decided by the component pointing at the centre of the nearest convex piece of the derived shape it opens into — reading form rather than the written allocation, so a space cut by a line still opens the right way.

const o = form.openings.find((x) => x.kind === "door" && x.swing)!;
console.log(JSON.stringify(o, null, 1));
{
 "ref": "/B1/park|/B1/mech@5/0",
 "boundary": 5,
 "index": 0,
 "a": "/B1/park",
 "b": "/B1/mech",
 "kind": "door",
 "level": "B1",
 "segment": {
  "x1": 16000,
  "y1": 12400,
  "x2": 16000,
  "y2": 16000,
  "horizontal": false,
  "edgeOfA": "E"
 },
 "cx": 16000,
 "cy": 14200,
 "w": 1200,
 "z0": -3700,
 "z1": -1700,
 "t": 250,
 "swing": {
  "into": "/B1/park",
  "hinge": {
   "x": 16000,
   "y": 13600
  },
  "leaf": {
   "x": 14800,
   "y": 13600
  },
  "jamb": {
   "x": 16000,
   "y": 14800
  },
  "ccw": false
 },
 "sliding": false
}

FormSeg

interface FormSeg {
ref: string; // `<the boundary's ref>~<seg index>`
boundary: number;
index: number;
level?: string;
segment: Segment;
cx: number; cy: number;
w: number;
t: number; // the band's thickness = wall thickness
}

Even an uncounted subdivision has a derived position. It appears in no area and in no graph, but where it is is a question of form.

FormColumn / FormRun / FormSite

interface FormColumn extends Column {
ref: string; // `<level name>/<grid pair>`
z0: number;
z1: number;
}

interface FormRun extends VerticalRun {
solids: RunSolid[];
}

interface FormSite {
path: string;
points: Pt[];
areaM2: number;
}

A column stands from z0 to z0 + pitch. On a level with no settled storey height, not one column stands.

console.log(form.columns[0]);
{
  x: 0,
  y: 0,
  w: 800,
  d: 800,
  level: 'B2',
  grid: 'X1/Y1',
  decl: 0,
  attrs: {},
  ref: 'B2/X1/Y1',
  z0: -7400,
  z1: -3700
}

VerticalRun and RunSolid are described under solids and generated fabric.

FormPlan — a plan is not a pure section

interface FormPlan {
level: string;
cut: number; // cut height above FL, mm
cutZ: number; // the cutting plane in world z, mm
entities: PlanEntity[];
}

interface PlanEntity {
class: PlanClass;
of: PlanSubject;
ref: string;
role?: PlanRole;
polygon?: Pt[];
lines?: Seg2[];
arc?: { cx: number; cy: number; r: number; from: Pt; to: Pt; ccw: boolean };
anchor?: { x: number; y: number; up?: boolean };
}

type PlanClass = "cut" | "below" | "above" | "swing" | "anchor";
type PlanSubject = "space" | "boundary" | "opening" | "column" | "run";
type PlanRole = "outline" | "tread" | "break" | "arrow";

Slicing a solid does not give you a plan. The arc of a door is a sign of movement; the projection of a void above lies above the cut; the break line marks where something was cut; the descending flight is what is visible below the cut. None of it is in a section.

So the plan is held as a classified set of 2D entities.

classWhat it is
cutwhat the cutting plane cut
belowwhat is visible below the cut
abovethe projection of what is above the cut
swingthe arc of movement (a door)
anchora seat for a symbol

A stretch carries both its footprint (polygon) and its centre line (lines). Whether to draw it as something with thickness or as a single line (a handrail that does not enclose) is a decision about appearance, so the consumer gets to make it.

const plan = form.plans.find((p) => p.level === "B1")!;
const count = new Map<string, number>();
for (const e of plan.entities) count.set(`${e.class}/${e.of}`, (count.get(`${e.class}/${e.of}`) ?? 0) + 1);
console.log(`level=${plan.level} cut=${plan.cut} cutZ=${plan.cutZ} entities=${plan.entities.length}`);
for (const [k, n] of [...count].sort()) console.log(` ${k} ${n}`);
level=B1 cut=1200 cutZ=-2500 entities=62
  above/boundary 2
  anchor/run 2
  below/run 5
  cut/boundary 19
  cut/column 15
  cut/opening 2
  cut/run 9
  cut/space 6
  swing/opening 2

One plan holds both the ascending and the descending run. On B1 the stair going up from B1 is cut at the break line, and beyond it the stair arriving from B2 is visible. That is why a stair looks different on every storey, and it is the plain fact that a plan is a section taken at that level.

The arc of a swing is absent for a sliding door — only the direction it slides remains, in lines.

Parts of the derivation

The parts derive uses can be called individually. What derive adds is the assembly — assemble it per consumer and the same source yields different buildings.

segmentsFor

interface Segment {
x1: number; y1: number; x2: number; y2: number;
horizontal: boolean; // horizontal means y1===y2, vertical means x1===x2
diagonal?: boolean; // not axis-parallel (a drawn line)
edgeOfA?: Edge; // which side of boundary.a's rectangle
}

function segmentsFor(model: Model, b: Boundary): Segment[]

This one function is the whole answer to where a wall appears. There is no operation that places a wall.

  • Both sides have a region → the axis-parallel edges the two regions share.
  • One side has no region (an exterior) → the perimeter minus the stretches faced by other rooms on the same level.
  • The boundary has a drawn line → the stretches of that line where the two sides are exactly a and b.
  • A vertical boundary (stair / shaft / void) → an empty array.

Shared edges and perimeters are taken from the derived shape (pieces), not the written allocation. From the allocation, a wall would stand out past the side a line cut away.

import { parse, segmentsFor } from "@kensnzk/koyu";

const g = parse(`grid X 0 3600 7200
grid Y 0 4000
level L1 0 h:2400 slab:200
space /L1/a room X1..X2 Y1..Y2
space /L1/b room X2..X3 Y1..Y2
space /out exterior
boundary /L1/a /L1/b t:120
door w:800
boundary /L1/a /out t:150`);

const bIn = g.boundaries.find((x) => x.b === "/L1/b")!;
console.log(segmentsFor(g, bIn));

const bOut = g.boundaries.find((x) => x.b === "/out")!;
for (const s of segmentsFor(g, bOut)) console.log(`edge:${s.edgeOfA} ${s.x1},${s.y1}${s.x2},${s.y2}`);
[
  {
    x1: 3600,
    y1: 0,
    x2: 3600,
    y2: 4000,
    horizontal: false,
    edgeOfA: 'E'
  }
]
edge:S 0,0 → 3600,0
edge:N 0,4000 → 3600,4000
edge:W 0,0 → 0,4000

Only three external segments come back because /L1/b occupies the E side of /L1/a. This is why a boundary to the exterior splits into several segments, and why placing an opening on one needs edge: to pick a side.

The compass is N=+Y, S=−Y, E=+X, W=−X. If you need a length, measure it from the endpoints yourself — koyu stops at where the segments are.

deriveDefaultBoundaries

function deriveDefaultBoundaries(model: Model): void

For every pair of region-bearing spaces on the same level that touch in plan and have no declared boundary at all, adds a kind:"wall" boundary to model.boundaries, marked derived: true. Contact is judged on the derived shapes, so a pair whose contact a line removed gets nothing.

Nothing is derived against a space with no region (an exterior). Naming that counterpart is itself information, so it is left to be declared.

Every parse function has already applied this on the way out. It is idempotent, so calling it again is harmless.

import { deriveDefaultBoundaries } from "@kensnzk/koyu";
const before = g.boundaries.length;
deriveDefaultBoundaries(g);
console.log(before, "→", g.boundaries.length);
2 → 2

The one time you must call it yourself is after building a Model from canonical JSON. Canonical JSON holds only the written composition, so the default walls have to be derived to read the meaning back.

placeOpening / placeBand

interface Band {
w: number; at: number;
atRef?: string; atAbs?: number; atAxis?: "X" | "Y";
edge?: Edge; line: number;
}

interface PlacedBand { segment: Segment; cx: number; cy: number }

interface BandError {
error: string; // the finished sentence, with position prefix
code: BandCode;
line: number;
file?: string;
message: string; // the body without the prefix
}

type BandCode =
| "OPN04" | "OPN05" | "OPN06" | "OPN07" | "OPN08"
| "SEG04" | "SEG05" | "SEG06" | "SEG07" | "SEG08";

function placeOpening(model: Model, b: Boundary, o: Opening): PlacedBand | BandError
function placeBand(model: Model, b: Boundary, band: Band, label: string): PlacedBand | BandError

Places an opening (or a seg) on a boundary segment and returns the absolute coordinates of its centre. When it cannot be placed, nothing is thrown — a BandError comes back; discriminate with "error" in result.

A label of "seg" selects the SEG codes; anything else ("door", "window") selects the OPN codes. placeOpening is nothing but placeBand(model, b, o, o.kind).

import { placeBand, placeOpening } from "@kensnzk/koyu";

console.log(placeOpening(g, bIn, bIn.openings[0]!));
console.log(placeOpening(g, bOut, bIn.openings[0]!)); // several segments — ambiguous
console.log(placeBand(g, bIn, { w: 1000, at: 0.25, line: 0 }, "seg"));
{
  segment: {
    x1: 3600,
    y1: 0,
    x2: 3600,
    y2: 4000,
    horizontal: false,
    edgeOfA: 'E'
  },
  cx: 3600,
  cy: 2000
}
{
  error: 'line 8: There is more than one boundary segment; pick an edge with edge:N/E/S/W (/L1/a | /out)',
  code: 'OPN05',
  line: 8,
  message: 'There is more than one boundary segment; pick an edge with edge:N/E/S/W (/L1/a | /out)'
}
{
  segment: {
    x1: 3600,
    y1: 0,
    x2: 3600,
    y2: 4000,
    horizontal: false,
    edgeOfA: 'E'
  },
  cx: 3600,
  cy: 1000
}

A ratio at is clamped to fit the segment. A grid reference (atAbs) is not, and running off the end is OPN08 / SEG08. On a diagonal segment (a drawn line) a grid reference does not fix a unique position, so it is OPN07 / SEG07 — write a ratio there.

DERIVATION_CONSTANTS

const DERIVATION_CONSTANTS: Readonly<Record<string, number>>

These decide what is derived when nothing was written. Write a value and the written value always wins. This is not a ledger of what may be written.

import { DERIVATION_CONSTANTS } from "@kensnzk/koyu";
console.log(DERIVATION_CONSTANTS);
{
  WALL_T: 100,
  RAIL_T: 60,
  RAIL_T_MAX: 80,
  RAIL_H: 1100,
  OPENING_HEAD: 2000,
  OPENING_H: 1200,
  CEILING_T: 30,
  ROOF_T: 200,
  CUT_HEIGHT: 1200,
  DEFAULT_RISER_MAX: 180,
  TREAD_TARGET: 300,
  LANDING_MIN: 1100,
  ENTRY_LANDING: 1100,
  LANE_ESCALATOR: 1200,
  TREAD_SOLID: 200,
  SLAB_T: 200,
  STEP_MARK: 400
}
NameDefault forOverridden by
WALL_Twall thickness, mm, split evenly about the centre linethe boundary's t:
RAIL_Tthickness of a non-enclosing boundary (air:1), mmthe boundary's t:
RAIL_T_MAXits ceiling — whatever t: says, it stops here
RAIL_Htop of a non-enclosing boundary, mmthe boundary's h:
OPENING_HEADhead height of an opening, mm; doors rise to it, everything else hangs from it
OPENING_Hheight of a non-door opening, mmthe opening's h:
CEILING_Tface thickness of a ceiling, mm
ROOF_Troof slab thickness where nothing is above, mmthe slab: of the level above
CUT_HEIGHTplan cut height above FL, mmthe cut option of derive
DEFAULT_RISER_MAXmaximum riser, mmriser:
TREAD_TARGETtarget tread when deriving a landing on a return stair, mmtread:
LANDING_MINminimum landing depth, mm
ENTRY_LANDINGdepth of the boarding floor, mmentry:
LANE_ESCALATORnominal width of one escalator, mmlane:
TREAD_SOLIDface thickness of a tread in the solid, mm
SLAB_Tthickness of a ramp or escalator deck, mm
STEP_MARKpitch of the escalator step marks in plan, mm

TOLERANCES

const TOLERANCES: Readonly<Record<string, number>>

These decide how different two things must be to be different things. One question must not have two tolerances, so they live in one place.

import { TOLERANCES } from "@kensnzk/koyu";
console.log(TOLERANCES);
{
  EPS: 0.5,
  AREA_EPS: 1,
  PROBE: 5,
  SPAN_EPS: 1,
  CROSS_EPS: 0.000001,
  PARALLEL_EPS: 1e-9,
  POINT_EPS: 1
}
NameUnitTolerance for
EPSmmlengths and coordinates: collinearity, facing edges, matching stretches, gaps in a collinear merge
AREA_EPSmm²degeneracy of area: a remnant this small is nothing
PROBEmmhow far to probe either side of a drawn line. This is the resolution floor of form — a space narrower than this cannot be assigned to either side
SPAN_EPSmmmatching of spans, cuts and frames: comparing z against the cut plane, the minimum visible run, the minimum envelope gap
CROSS_EPScross productthe sign when cutting by a half-plane: keep the vertex, or insert the crossing
PARALLEL_EPSdimensionlessparallelism of an infinite line and a segment, and the range of the segment parameter
POINT_EPSmmhow wide a band counts as "on the polygon's edge" (on the boundary is inside)

Coordinates are integers of millimetres by default. So the 0.5 mm length tolerance sits at half the integer step. The 1 mm² area tolerance is a 1 mm × 1 mm sliver: a remnant that small after a half-plane cut is not counted as a shape.

See also