Add atomic Revit initial import

This commit is contained in:
2026-08-02 17:33:32 +02:00
parent e87a3289fa
commit c634570018
16 changed files with 3150 additions and 8 deletions
@@ -0,0 +1,134 @@
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const externalInitialImportCommandType =
"external-import.apply-initial" as const;
export const externalInitialImportCommandSchemaVersion = 1 as const;
export interface ExternalInitialImportPayload {
expected: ExternalModelStateSnapshot;
target: ExternalModelStateSnapshot;
}
export interface ExternalInitialImportProjectCommand
extends SerializedProjectCommand<ExternalInitialImportPayload> {
schemaVersion: typeof externalInitialImportCommandSchemaVersion;
type: typeof externalInitialImportCommandType;
}
export function createExternalInitialImportProjectCommand(
expected: ExternalModelStateSnapshot,
target: ExternalModelStateSnapshot
): ExternalInitialImportProjectCommand {
const command: ExternalInitialImportProjectCommand = {
schemaVersion: externalInitialImportCommandSchemaVersion,
type: externalInitialImportCommandType,
payload: { expected, target },
};
assertExternalInitialImportProjectCommand(command);
return command;
}
export function assertExternalInitialImportProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ExternalInitialImportProjectCommand {
if (
command.schemaVersion !== externalInitialImportCommandSchemaVersion ||
command.type !== externalInitialImportCommandType ||
!isRecord(command.payload) ||
Object.keys(command.payload).length !== 2
) {
throw new Error("Unsupported external initial import command.");
}
const expected = parseExternalModelStateSnapshot(command.payload.expected);
const target = parseExternalModelStateSnapshot(command.payload.target);
const expectedIsEmpty = isEmptyExternalModelState(expected);
const targetIsEmpty = isEmptyExternalModelState(target);
if (expectedIsEmpty === targetIsEmpty) {
throw new Error("External initial import must transition between empty and populated state.");
}
assertPopulatedInitialState(expectedIsEmpty ? target : expected);
}
export function createEmptyExternalModelState(): ExternalModelStateSnapshot {
return { source: null, importBatches: [], roomMappings: [], objects: [] };
}
export function isEmptyExternalModelState(state: ExternalModelStateSnapshot) {
return (
state.source === null &&
state.importBatches.length === 0 &&
state.roomMappings.length === 0 &&
state.objects.length === 0
);
}
function assertPopulatedInitialState(state: ExternalModelStateSnapshot) {
if (state.source === null || state.importBatches.length !== 1 || state.objects.length === 0) {
throw new Error("External initial import requires one source, one batch and at least one object.");
}
const source = state.source;
const batch = state.importBatches[0];
if (
batch.importKind !== "initial" ||
batch.projectId !== source.projectId ||
batch.sourceId !== source.id
) {
throw new Error("External initial import batch identity is invalid.");
}
const mappingIds = uniqueIds(state.roomMappings, "room mapping");
const roomKeys = new Set<string>();
for (const mapping of state.roomMappings) {
if (mapping.projectId !== source.projectId || mapping.sourceId !== source.id) {
throw new Error("External initial room mapping identity is invalid.");
}
if (roomKeys.has(mapping.normalizedSourceRoomKey)) {
throw new Error("External initial import contains duplicate room keys.");
}
roomKeys.add(mapping.normalizedSourceRoomKey);
}
uniqueIds(state.objects, "object");
const ifcGuids = new Set<string>();
for (const object of state.objects) {
if (
object.projectId !== source.projectId ||
object.sourceId !== source.id ||
object.lastSeenImportBatchId !== batch.id ||
object.lastAcceptedImportBatchId !== batch.id
) {
throw new Error("External initial object identity is invalid.");
}
if (ifcGuids.has(object.ifcGuid)) {
throw new Error("External initial import contains duplicate IFCGUIDs.");
}
ifcGuids.add(object.ifcGuid);
if (
object.externalRoomMappingId !== null &&
!mappingIds.has(object.externalRoomMappingId)
) {
throw new Error("External initial object room mapping is invalid.");
}
if (object.circuitDeviceRowId !== null) {
throw new Error("External initial import must not assign circuit device rows.");
}
if (object.presenceStatus !== "present") {
throw new Error("External initial import objects must be present.");
}
}
}
function uniqueIds(entries: ReadonlyArray<{ id: string }>, label: string) {
const ids = new Set<string>();
for (const entry of entries) {
if (ids.has(entry.id)) {
throw new Error(`External initial import contains duplicate ${label} ids.`);
}
ids.add(entry.id);
}
return ids;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -24,6 +24,7 @@ import {
externalImportKinds,
externalModelSourceTypes,
externalObjectPresenceStatuses,
type ExternalModelStateSnapshot,
} from "../../external-model/domain/external-model-contracts.js";
export const projectStateSnapshotSchemaVersion = 4 as const;
@@ -333,6 +334,7 @@ const externalImportBatchSchema = z.object({
fileName: z.string().trim().min(1),
sha256: z.string().regex(/^[a-f0-9]{64}$/),
appliedProjectRevision: z.number().int().nonnegative(),
configurationVersion: z.number().int().positive(),
configurationSnapshot: z.custom<ExternalCsvConfiguration>(
(value) => validateExternalCsvConfiguration(value).success
),
@@ -414,6 +416,12 @@ const externalModelStateSchema = z.object({
objects: z.array(externalModelObjectSchema),
}).strict();
export function parseExternalModelStateSnapshot(
value: unknown
): ExternalModelStateSnapshot {
return externalModelStateSchema.parse(value);
}
const projectStateSnapshotContents = {
circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema),