mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-18 22:33:33 +00:00
240 lines
10 KiB
JavaScript
240 lines
10 KiB
JavaScript
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
// The federation half of the scripting API: false origin, per-model transform,
|
||
|
|
// and the model-loaded event.
|
||
|
|
//
|
||
|
|
// Why this exists: the web viewer used to render every model in its own local
|
||
|
|
// coordinates, because nothing applied a model's IfcCoordinateOperation. Two
|
||
|
|
// federated models whose map conversions resolve to the same real-world point
|
||
|
|
// came out misaligned. Models now resolve to global coordinates on load, and
|
||
|
|
// the first one sets a false origin so the scene stays near the origin —
|
||
|
|
// composed per-instance transforms are float32, and surveyor coordinates would
|
||
|
|
// otherwise quantise at around half a metre.
|
||
|
|
//
|
||
|
|
// Runs against the embedded sample, which carries NO IfcCoordinateOperation.
|
||
|
|
// That is deliberate here: it pins the federation plumbing (staging, applying,
|
||
|
|
// events, units) without needing a georeferenced fixture. The georef values
|
||
|
|
// themselves are covered by the sidecar round-trip tests in
|
||
|
|
// src/ifcviewer/tests/test_sidecar_cache.cpp.
|
||
|
|
|
||
|
|
async function open(page) {
|
||
|
|
const errors = [];
|
||
|
|
page.on('console', (msg) => {
|
||
|
|
const t = msg.text();
|
||
|
|
if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(t)) errors.push(t);
|
||
|
|
});
|
||
|
|
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
|
||
|
|
|
||
|
|
await page.goto('/scripting.html');
|
||
|
|
await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), null,
|
||
|
|
{ timeout: 30_000 });
|
||
|
|
await page.waitForFunction(() => {
|
||
|
|
const v = window.viewer;
|
||
|
|
if (!v.modelCount()) return false;
|
||
|
|
const p = v.modelProgress(0);
|
||
|
|
return p.total > 0 && p.resident === p.total;
|
||
|
|
}, null, { timeout: 30_000 });
|
||
|
|
return errors;
|
||
|
|
}
|
||
|
|
|
||
|
|
// viewAll frames the union of resident chunks' world AABBs. Shifting the false
|
||
|
|
// origin moves every instance, which changes what the cull considers visible
|
||
|
|
// and can therefore change residency. Settle before each measurement, or the
|
||
|
|
// two frames being compared describe different subsets of the model.
|
||
|
|
async function settled(page) {
|
||
|
|
await page.waitForFunction(() => {
|
||
|
|
const v = window.viewer;
|
||
|
|
if (!v.modelCount()) return false;
|
||
|
|
for (let i = 0; i < v.modelCount(); ++i) {
|
||
|
|
const p = v.modelProgress(i);
|
||
|
|
if (!(p.total > 0 && p.resident === p.total)) return false;
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}, null, { timeout: 30_000 });
|
||
|
|
}
|
||
|
|
|
||
|
|
async function frameAndReadTarget(page) {
|
||
|
|
// Frame FIRST, then wait. Chunks stream on visibility, so a model sitting off
|
||
|
|
// screen — which is exactly what happens right after the origin moves — never
|
||
|
|
// becomes resident and a settle-then-frame order would deadlock. The initial
|
||
|
|
// viewAll can work from the metadata AABBs, which are known before any
|
||
|
|
// geometry has landed.
|
||
|
|
await page.evaluate(() => window.viewer.viewAll());
|
||
|
|
await settled(page);
|
||
|
|
await page.evaluate(() => window.viewer.viewAll());
|
||
|
|
return page.evaluate(() => window.viewer.getCamera().target);
|
||
|
|
}
|
||
|
|
|
||
|
|
test('false origin: guessed for the first model, and overridable', async ({ page }) => {
|
||
|
|
const errors = await open(page);
|
||
|
|
|
||
|
|
// The sample loads before any host code runs, so by now the automatic guess
|
||
|
|
// has already happened. It must NOT be flagged explicit — that flag is what
|
||
|
|
// tells the viewer a host has taken over placement.
|
||
|
|
const guessed = await page.evaluate(() => window.viewer.getFalseOrigin());
|
||
|
|
expect(guessed).not.toBeNull();
|
||
|
|
expect(guessed.explicit).toBe(false);
|
||
|
|
expect(guessed.xyz).toHaveLength(3);
|
||
|
|
guessed.xyz.forEach((v) => expect(Number.isFinite(v)).toBe(true));
|
||
|
|
expect(Number.isFinite(guessed.rzDeg)).toBe(true);
|
||
|
|
|
||
|
|
// Setting one takes over: the value round-trips and the flag flips, which is
|
||
|
|
// what suppresses the guess for any later first-model load.
|
||
|
|
const set = await page.evaluate(() => {
|
||
|
|
window.viewer.setFalseOrigin({ xyz: [10, 20, 30], rzDeg: 45 });
|
||
|
|
return window.viewer.getFalseOrigin();
|
||
|
|
});
|
||
|
|
expect(set.xyz[0]).toBeCloseTo(10, 6);
|
||
|
|
expect(set.xyz[1]).toBeCloseTo(20, 6);
|
||
|
|
expect(set.xyz[2]).toBeCloseTo(30, 6);
|
||
|
|
expect(set.rzDeg).toBeCloseTo(45, 6);
|
||
|
|
expect(set.explicit).toBe(true);
|
||
|
|
|
||
|
|
expect(errors).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('false origin shifts the scene by the negated offset', async ({ page }) => {
|
||
|
|
const errors = await open(page);
|
||
|
|
|
||
|
|
// A false origin nominates a point as the new origin, so geometry moves by
|
||
|
|
// -offset. This is the assertion that would fail if the origin were stored
|
||
|
|
// but never composed into the per-instance transforms.
|
||
|
|
await page.evaluate(() => window.viewer.setFalseOrigin({ xyz: [0, 0, 0], rzDeg: 0 }));
|
||
|
|
const before = await frameAndReadTarget(page);
|
||
|
|
|
||
|
|
await page.evaluate(() => window.viewer.setFalseOrigin({ xyz: [100, 0, 0], rzDeg: 0 }));
|
||
|
|
const after = await frameAndReadTarget(page);
|
||
|
|
|
||
|
|
expect(after[0] - before[0]).toBeCloseTo(-100, 1);
|
||
|
|
expect(after[1] - before[1]).toBeCloseTo(0, 1);
|
||
|
|
expect(after[2] - before[2]).toBeCloseTo(0, 1);
|
||
|
|
|
||
|
|
expect(errors).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('model transform moves one model and clears back', async ({ page }) => {
|
||
|
|
const errors = await open(page);
|
||
|
|
|
||
|
|
// The embedded sample bypasses the source registry, so it has no source id to
|
||
|
|
// address. Add the same sidecar as a real source — the flow a host page
|
||
|
|
// actually uses — and wait for the load event to hand back its ids.
|
||
|
|
const detail = await page.evaluate(async () => {
|
||
|
|
const v = window.viewer;
|
||
|
|
const loaded = new Promise((resolve) => {
|
||
|
|
const off = v.onModelLoaded((d) => { off(); resolve(d); });
|
||
|
|
});
|
||
|
|
const sid = await v.addUrl('/sample.ifcview', { replace: true, name: 'placed' });
|
||
|
|
const d = await loaded;
|
||
|
|
// Pin the origin so the automatic guess cannot move things underneath us.
|
||
|
|
v.setFalseOrigin({ xyz: [0, 0, 0], rzDeg: 0 });
|
||
|
|
return { ...d, sid };
|
||
|
|
});
|
||
|
|
|
||
|
|
// onModelLoaded must carry the source id it was asked for, and a real
|
||
|
|
// session model id (the core numbers them from 1).
|
||
|
|
expect(detail.sourceId).toBe(detail.sid);
|
||
|
|
expect(detail.sessionModelId).toBeGreaterThan(0);
|
||
|
|
|
||
|
|
const base = await frameAndReadTarget(page);
|
||
|
|
|
||
|
|
// "Take the point a=(0,0,0) and put it at b=(50,0,0)."
|
||
|
|
await page.evaluate((sid) => window.viewer.setModelTransform(
|
||
|
|
sid, { a: [0, 0, 0], b: [50, 0, 0], aFrame: 'global' }), detail.sid);
|
||
|
|
const shifted = await frameAndReadTarget(page);
|
||
|
|
|
||
|
|
await page.evaluate((sid) => window.viewer.clearModelTransform(sid), detail.sid);
|
||
|
|
const cleared = await frameAndReadTarget(page);
|
||
|
|
|
||
|
|
expect(shifted[0] - base[0]).toBeCloseTo(50, 1);
|
||
|
|
// Clearing restores the untransformed placement rather than leaving the model
|
||
|
|
// where it was put.
|
||
|
|
expect(cleared[0]).toBeCloseTo(base[0], 1);
|
||
|
|
|
||
|
|
expect(errors).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('georef readback reports the sample carries no coordinate operation', async ({ page }) => {
|
||
|
|
const errors = await open(page);
|
||
|
|
|
||
|
|
const georef = await page.evaluate(async () => {
|
||
|
|
const v = window.viewer;
|
||
|
|
const loaded = new Promise((resolve) => {
|
||
|
|
const off = v.onModelLoaded((d) => { off(); resolve(d); });
|
||
|
|
});
|
||
|
|
const sid = await v.addUrl('/sample.ifcview', { replace: true });
|
||
|
|
await loaded;
|
||
|
|
return v.getModelGeoref(sid);
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(georef).not.toBeNull();
|
||
|
|
// make_sample.py authors no IfcMapConversion, so the flag is down and the
|
||
|
|
// matrix stays the identity placeholder.
|
||
|
|
expect(georef.hasCoordinateOperation).toBe(false);
|
||
|
|
expect(georef.matrix).toHaveLength(16);
|
||
|
|
expect(georef.matrix[0]).toBeCloseTo(1, 9);
|
||
|
|
expect(georef.matrix[5]).toBeCloseTo(1, 9);
|
||
|
|
expect(georef.matrix[10]).toBeCloseTo(1, 9);
|
||
|
|
expect(georef.matrix[15]).toBeCloseTo(1, 9);
|
||
|
|
expect(georef.projectLengthToMeters).toBeGreaterThan(0);
|
||
|
|
expect(georef.mapUnitToMeters).toBeGreaterThan(0);
|
||
|
|
|
||
|
|
expect(errors).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
// The bug this whole path exists to fix: two models whose DIFFERENT
|
||
|
|
// IfcMapConversions resolve to the SAME real-world point must be drawn on top
|
||
|
|
// of each other.
|
||
|
|
//
|
||
|
|
// georef-a and georef-b (see make_sample.py) are 2 m boxes whose local
|
||
|
|
// positions differ by ~707 m and whose coordinate operations cancel that out
|
||
|
|
// exactly. A viewer that applies each model's operation frames one box's worth
|
||
|
|
// of scene after loading both; one that ignores it — as this viewer did before
|
||
|
|
// applyCachedModel seeded georef from the sidecar — frames ~707 m of empty
|
||
|
|
// space between them. The gap is two orders of magnitude larger than the
|
||
|
|
// geometry, so this cannot pass by accident.
|
||
|
|
test('models with different map conversions resolving to one point align', async ({ page }) => {
|
||
|
|
const errors = await open(page);
|
||
|
|
|
||
|
|
// Pin the origin AFTER the replacing load: replace:true clears the scene,
|
||
|
|
// which resets federation state including an explicitly-set origin. Pinning
|
||
|
|
// matters because the automatic guess is derived from the FIRST model, so
|
||
|
|
// leaving it on would shift the scene between the two measurements.
|
||
|
|
await page.evaluate(async () => {
|
||
|
|
const v = window.viewer;
|
||
|
|
const loaded = new Promise((resolve) => {
|
||
|
|
const off = v.onModelLoaded(() => { off(); resolve(); });
|
||
|
|
});
|
||
|
|
await v.addUrl('/georef-a.ifcview', { replace: true, name: 'a' });
|
||
|
|
await loaded;
|
||
|
|
v.setFalseOrigin({ xyz: [0, 0, 0], rzDeg: 0 });
|
||
|
|
});
|
||
|
|
const alone = await frameAndReadTarget(page);
|
||
|
|
const aloneDistance = await page.evaluate(() => window.viewer.getCamera().distance);
|
||
|
|
|
||
|
|
// Append the second model. Aligned, it adds nothing to the scene's extent.
|
||
|
|
await page.evaluate(async () => {
|
||
|
|
const v = window.viewer;
|
||
|
|
const loaded = new Promise((resolve) => {
|
||
|
|
const off = v.onModelLoaded(() => { off(); resolve(); });
|
||
|
|
});
|
||
|
|
await v.addUrl('/georef-b.ifcview', { name: 'b' });
|
||
|
|
await loaded;
|
||
|
|
});
|
||
|
|
const both = await frameAndReadTarget(page);
|
||
|
|
const bothDistance = await page.evaluate(() => window.viewer.getCamera().distance);
|
||
|
|
|
||
|
|
// Same centre: within a box's width, not hundreds of metres away.
|
||
|
|
expect(Math.abs(both[0] - alone[0])).toBeLessThan(2);
|
||
|
|
expect(Math.abs(both[1] - alone[1])).toBeLessThan(2);
|
||
|
|
expect(Math.abs(both[2] - alone[2])).toBeLessThan(2);
|
||
|
|
|
||
|
|
// And the framing does not blow out to span a 707 m gap.
|
||
|
|
expect(bothDistance).toBeLessThan(aloneDistance * 2);
|
||
|
|
|
||
|
|
// Both models really are in the scene — otherwise the assertions above would
|
||
|
|
// pass trivially on a failed second load.
|
||
|
|
expect(await page.evaluate(() => window.viewer.modelCount())).toBe(2);
|
||
|
|
|
||
|
|
expect(errors).toEqual([]);
|
||
|
|
});
|