importIfc rounds a storey's elevation to whole millimetres

Open Beginner friendly
#62 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
78/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
typescript
Domain
backend, databases

Research direction

Start at packages/bim-kernel/src/import/plan.ts:100, where importIfc rounds storey.elevationMm, and compare it with the precision rule in packages/bim-kernel/src/import/rules.ts:24. Run the supplied Jest reproduction in packages/bim-ifc/__tests__/zz-gap-8-elevation.test.ts; done means Level 2 imports at 3657.6 mm and the test passes.

Written by the indexing model from the issue text.

Description

bug
What happened

small-office puts Level 2 at 12 ft, which is 3657.6 mm. The export writes 3657.6 (observed, checked in #57). importIfc brings it back at 3658 (observed):

stored: 3657.6 imported: [["Level 1",0],["Level 2",3658]]

Cause (observed): packages/bim-kernel/src/import/plan.ts:100, elevationMm: Math.round(storey.elevationMm).

This is 0.4 mm, inside the 0.5 mm that i9-convert-sample allows a conversion. But walls and parts are placed relative to the level, so imported offsets also shift. A part sitting on Level 2 comes back 0.4 mm off its storey (inferred, not measured). Every imperial storey height that isn't a whole number of mm is affected (inferred).

An AI agent helped find this and ran the repro on its own machine against 94513ba; I reviewed the output.

What you expected

The level keeps the storey's elevation to the same precision as the other imported lengths. Those go through round(), which keeps 0.1 mm: packages/bim-kernel/src/import/rules.ts:24, Math.round(n * 10) / 10 (observed). Level 2 should come back at 3657.6.

Steps to reproduce
  1. Check out 94513ba (current main); #57 is not needed. Save the helpers below as packages/bim-ifc/__tests__/zz-gap-support.ts. They are the same build, export and read-back steps as examples-ifc.test.ts in #57.
  2. Save the test below as packages/bim-ifc/__tests__/zz-gap-8-elevation.test.ts.
  3. Run npx jest packages/bim-ifc/__tests__/zz-gap-8-elevation.test.ts on Node 22.
  4. The test fails today. It should pass once this is fixed.
zz-gap-support.ts (shared helpers)
/**
 * Scratch support for the #40 gap repros (not for commit). The same build,
 * export and read-back steps as examples-ifc.test.ts in PR #57.
 */
import { DEFAULT_SETTINGS, asRevision, gatedRecordFeatures, type BimElement, type ModelState } from "@permitzip/bim-kernel";
import { runScript, sliceModel, type ScriptDefinition } from "@permitzip/bim-script";
import { PROTOCOL_FEATURES } from "@permitzip/bim-sync";
import { exportIfc, importIfc, readStep, type ReadEntity, type StepFile } from "../src";
import { Log } from "../../bim-kernel/__tests__/a13a-fixtures";
import { ofType, ref, refs, text } from "./a13a-ifc-support";

const FEATURES = [...new Set([...Object.values(PROTOCOL_FEATURES), ...gatedRecordFeatures()])];
export const PROJECT = { id: "2c9a7e14-5b3d-4f86-a1e0-7d4c3b2a1f09", name: "Synthetic examples" };
export const TIME_STAMP = "2026-11-12T00:00:00";

/** Runs each script on an empty model and commits its edits; returns the log so a repro can add commands. */
export async function build(...scripts: ScriptDefinition[]): Promise<Log> {
  const m = new Log({ modelId: "examples", revision: asRevision(0), elements: {}, connections: [], settings: DEFAULT_SETTINGS });
  for (const [i, def] of scripts.entries()) {
    const out = await runScript(def, { slice: sliceModel(m.head, def.manifest.reads, [], FEATURES), values: {}, scriptHash: `sha256:gap-${i}` });
    if (out.kind !== "edits") throw new Error(`expected edits, got ${out.kind}`);
    for (const batch of out.batches) m.ok(batch);
  }
  return m;
}

/** The export, its STEP text read back, and a lookup from a model id to its IFC entity. */
export function exported(model: ModelState) {
  const out = exportIfc({ model, project: PROJECT, timeStamp: TIME_STAMP });
  const file = readStep(out.text);
  const entityOf = (id: string): ReadEntity => {
    const guid = out.report.globalIds[id];
    const e = [...file.entities.values()].find((x) => x.args[0]?.kind === "string" && x.args[0].value === guid);
    if (!e) throw new Error(`no entity for ${id}`);
    return e;
  };
  return { text: out.text, file, entityOf, report: out.report };
}

/** Property set name → property name → value, on an entity (and its type's HasPropertySets for a type). */
export function psets(file: StepFile, e: ReadEntity): Record<string, Record<string, unknown>> {
  const ids = ofType(file, "IFCRELDEFINESBYPROPERTIES").filter((r) => refs(r, 4).includes(e.id)).map((r) => ref(r, 5));
  if (e.type.endsWith("TYPE")) ids.push(...refs(e, 5));
  const out: Record<string, Record<string, unknown>> = {};
  for (const set of ids.flatMap((id) => file.entities.get(id) ?? [])) {
    const props: Record<string, unknown> = {};
    for (const p of refs(set, set.type === "IFCPROPERTYSET" ? 4 : 5).flatMap((id) => file.entities.get(id) ?? [])) {
      const v = p.type === "IFCPROPERTYSINGLEVALUE" ? p.args[2] : p.args[3];
      props[text(p, 0) ?? "?"] = v?.kind === "typed" && "value" in v.value ? v.value.value : v?.kind === "number" ? v.value : null;
    }
    out[text(set, 2) ?? "?"] = props;
  }
  return out;
}

/** importIfc of the export into an empty model: its records, its report, and the record for a model element. */
export function readBack(model: ModelState) {
  const { text: ifc, report } = exportIfc({ model, project: PROJECT, timeStamp: TIME_STAMP });
  const empty: ModelState = { modelId: "readback", revision: asRevision(0), elements: {}, connections: [], settings: DEFAULT_SETTINGS };
  const result = importIfc(ifc, { model: empty, referenceId: "ref", sourceKey: "rt", sourceId: "rt", label: "Round trip", disciplines: ["architecture", "mechanical", "electrical"] });
  const byGuid = new Map<string, BimElement>();
  for (const r of result.records) {
    const guid = "provenance" in r && r.provenance ? r.provenance.ifcGlobalId : r.kind === "level" ? r.source?.storeyId : undefined;
    if (guid) byGuid.set(guid, r);
  }
  const of = (el: BimElement): BimElement | undefined => byGuid.get(report.globalIds[el.id] ?? "");
  return { ...result, of };
}

export const ofKind = <K extends BimElement["kind"]>(els: ModelState | readonly BimElement[], kind: K) =>
  ("elements" in els ? Object.values(els.elements) : els).filter((e): e is Extract<BimElement, { kind: K }> => e.kind === kind);
/** @jest-environment node */
// Repro: importIfc rounds a storey elevation to whole millimetres.
import smallOffice from "../../bim-script/examples/small-office";
import { build, ofKind, readBack } from "./zz-gap-support";

it("small-office: Level 2 comes back at 3657.6 mm", async () => {
  const m = await build(smallOffice);
  const l2 = ofKind(m.head, "level").find((l) => l.name === "Level 2")!;
  const back = readBack(m.head);
  const got = ofKind(back.records, "level").map((l) => [l.name, l.elevationMm]);
  console.log("stored:", l2.elevationMm, "imported:", JSON.stringify(got));
  expect(ofKind(back.records, "level").find((l) => l.name === "Level 2")?.elevationMm).toBeCloseTo(l2.elevationMm, 3);
});
Area

Kernel (model, commands, routing)

Discipline (if any)

No response

Commit or version

94513ba

Environment

Linux x64, Node 22.23.4, Jest via npx jest

Logs or output
stored: 3657.6 imported: [["Level 1",0],["Level 2",3658]]
  ● small-office: Level 2 comes back at 3657.6 mm
    Expected: 3657.6
    Received: 3658
Before you submit
  • I searched existing issues and this is not a duplicate.
  • This report contains no client, project or personal data.
Dominant language
TypeScript
Stars
85
Forks
10
Avg merge
1h 28m
Merged PRs (30d)
20

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from permitzip/mutinybim

All issues in permitzip/mutinybim

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.