C ABI
Source/API is the boundary between the core and its frontends: Unreal, the
Blender server, and the tests. This page is the map; the comments in the headers
contain the authoritative details.
Release 0.0.1, ABI 25. See UC_GetVersion and UC_GetAbiVersion. The ABI
history is recorded in Types/APIAbi.h.
Units and coordinate system
- Lengths are in centimetres and angles are in radians. Values at the
boundary use single precision (
float); the core calculates in double precision, and conversion happens in the API layer and nowhere else. - The coordinate system is right-handed: X forward, Y left, Z up. Rotation
around Z (yaw:
YawRadians,RotationYaw) is measured from +X towards +Y. A wall's left face is on its +Y side, to the left when looking along the wall's direction. - A face is front-facing when its triangle (V0, V1, V2) is counter-clockwise as
viewed from the normal. For UV mapping,
U × V = N. - A left-handed system (Unreal: X forward, Y right) must invert Y on its side; the core performs no conversion.
- Wall and slab coordinates, both on input and in the mesh (
Origin, vertices), are relative to the elevation of their story (ABI 25). The core stores the elevation but never adds it; see “Stories”.
Headers
Include everything at once:
#include <UniqueCore/UniqueCore.h>
| Header | Contents |
|---|---|
App/APIApp.h | UC_Initialize, UC_Shutdown |
Core/APICore.h | UC_GetVersion, the failure reason (UC_GetLastError), and logging (UC_SetLogCallback, UC_SetLogLevel, UC_GetLogLevel) |
Types/APIAbi.h | UC_ABI_VERSION, UC_GetAbiVersion, and the ABI history |
Types/APIResult.h | UC_Result, UC_GetResultName |
Types/APIHandles.h | House, story, wall, opening, and slab handles |
Types/APIMesh.h | The “KMSH” mesh block |
House/APIHouse.h | Houses |
House/APIStory.h | Stories: UC_CreateStory, UC_UpdateStory, UC_DeleteStory |
House/APIWall.h | Walls |
House/APIOpening.h | Openings |
House/APIJoinery.h | Joinery |
House/APISlab.h | Slabs |
The headers support both C and C++ and use only <stdint.h> and plain
structures. UNIQUE_CORE_EXTERN_C and UNIQUE_CORE_NOEXCEPT expand according
to the language.
Data ownership
The core stores only one graph per house: its stories, walls, their intersections, openings, and slabs. UV mapping, materials, and joinery parameters belong to the frontend and are supplied with every mesh request. The core does not store meshes either; it builds them on demand.
The frontend is the source of truth for everything visible; the core is the source of truth for graph geometry.
Result codes
UC_Result is an int32_t, not a C enum, so its size at the boundary is fixed.
| Value | Name | Meaning |
|---|---|---|
0 | UC_Result_Success | Success |
1 | UC_Result_InvalidArgument | Null pointer, NaN, invalid size, or a handle that is not live in this house |
2 | UC_Result_InternalError | Internal error; the exception is written to the log and to UC_GetLastError |
3 | UC_Result_NotInitialized | The core is not initialized |
4 | UC_Result_NotImplemented | The command is present in the API, but this release does not implement it yet |
5 | UC_Result_Overflow | The caller's array is too small; the count reports the required size |
UC_GetResultName returns the code name for logging. For an unknown code it
returns "UC_Result_Unknown", never NULL.
Failure reason: UC_GetLastError
Since ABI 25 (docs/integration.md D20), every result other than
UC_Result_Success also leaves a human-readable reason.
UNIQUE_CORE_EXTERN_C UNIQUE_CORE_API const char* UC_GetLastError(void) UNIQUE_CORE_NOEXCEPT;
| What | The last failure message on the current thread: UTF-8, null-terminated, in Bulgarian, and beginning with the function name, for example "UC_IntersectWalls: [Bulgarian failure reason: Parallel]" |
| After success | An empty string "", not NULL. This is also returned on a thread that has not called anything yet |
| What writes it | Every boundary failure: InvalidArgument, NotInitialized, Overflow, InternalError; argument checks, dead handles, graph rejections (the UC_IntersectWalls cases and story checks), degenerate walls, and exceptions caught by Guard |
| What clears it | The next successful UC_ call on the same thread; a failing call replaces it with its own reason |
| What leaves it unchanged | UC_GetLastError and UC_GetResultName: both may be called after a failure, in either order |
| Lifetime | The pointer is owned by the core and remains valid until the next UC_ call on the thread. Copy it if it is needed later |
| Logging | Independent of the log level: a Release core (logging Error and above) returns the same text as a Debug core |
| Reset | Does not require UC_Initialize and is not lost on UC_Shutdown; a subsequent success clears it |
| Length | Up to 1023 bytes; longer messages are truncated at a character boundary |
Consumer behaviour: after every result other than UC_Result_Success, read
UC_GetLastError() immediately and on the same thread, before the next
UC_ call, including UC_SetLogCallback, and include it in the error next to
UC_GetResultName(code). KUB does this in one place in
KristeniCoreLocalBackend.cpp. The server uses ApplyError.message_user /
message_log.
The message is for humans. Programs branch on the result code; they must not compare or parse the text because its wording may change without an ABI bump. Translation is not the core's responsibility (D11).
The storage is a fixed-size array per thread with no destructor: writing does not allocate or throw. Once the library is unloaded, the pointer is no longer valid, just like a mesh-buffer pointer.
Sizes
These are checked by a C test on every build.
| Type | Bytes |
|---|---|
UC_Result | 4 |
UC_Version | 12 |
UC_HouseHandle, UC_StoryHandle, UC_WallHandle, UC_OpeningHandle, UC_SlabHandle | 16 |
UC_Vector2f | 8 |
UC_Vector3f | 12 |
UC_UVData | 20 |
UC_WallRequest | 64 |
UC_WallParameters | 48 (44 bytes of fields + 4 bytes of trailing padding) |
UC_OpeningParameters | 16 |
UC_SlabParameters | 24 (20 bytes of fields + 4 bytes of trailing padding) |
UC_FrameParameters | 20 |
UC_DoorSashParameters | 4 |
UC_SingleSashWindowParameters | 60 |
UC_SingleSashDoorParameters | 64 |
UC_DoubleSashWindowParameters | 68 |
UC_MeshHeader | 36 |
UC_SurfaceHeader | 16 |
UC_MeshVertexInstance | 36 |
UC_StoryHandle raises the alignment of UC_WallParameters and
UC_SlabParameters to 8, which is why each has 4 bytes of trailing padding.
A ctypes.Structure wrapper gets this automatically when fields are declared in
header order; do not add a padding field manually.
Lifecycle
UC_GetVersion, UC_GetAbiVersion,
UC_SetLogCallback, UC_SetLogLevel,
UC_GetLogLevel, UC_GetResultName,
UC_GetLastError at any time
UC_Initialize
UC_CreateHouse only while the core is initialized
UC_CreateStory before the first wall or slab
UC_BuildWall, UC_BuildSlab, ...
UC_DeleteStory
UC_DeleteHouse
UC_Shutdown
UC_Initializeis idempotent: a second call succeeds.UC_Shutdowncalled while the core is not initialized returnsUC_Result_NotInitialized.- After
UC_Shutdown, all handles are invalid and remain invalid after a newUC_Initialize. Generations do not restart, so an old handle cannot resolve to a new house. - The C ABI is single-threaded: the host calls one
UC_*function at a time and is responsible for serializing calls.
Reset
UC_Shutdown(); UC_Initialize(); is the intended way to start from a clean
state between levels, scenes, or documents. There is no UC_Reset, and there
will not be one.
- It is safe on an empty core (initialized, with no houses) and may be repeated as often as necessary within one session.
- The only cost is releasing house memory; their graphs are discarded. There is no additional work.
- Logging is unaffected: the callback set by
UC_SetLogCallbackand the level set byUC_SetLogLevelremain in place and do not need to be supplied again. - The thread-local mesh buffer (see “Meshes”) is not released; the next mesh request reuses it.
- Every handle created before the reset returns
UC_Result_InvalidArgumentafterwards (see above).
Logging
UC_SetLogCallback redirects logging to a host callback; NULL restores
std::cout. The callback receives the level as an int32_t, from
UC_LogLevel_Trace (0) through UC_LogLevel_Critical (5), together with the
category and message.
UC_SetLogLevel sets the lowest level delivered to the callback, and
UC_GetLogLevel returns it (ABI 24):
- A level outside 0..5 returns
UC_Result_InvalidArgument, and the existing level remains unchanged; it is not clamped.UC_GetLogLevel(NULL)does the same. - The level cannot be lower than the compiled level
(
KRISTENI_COMPILED_LOG_LEVEL: Debug includes everything, while Release includes only Error and Critical). Lower-severity messages are removed at compile time. A request below the compiled level is nevertheless accepted withUC_Result_Success, andUC_GetLogLevelreturns the requested level, but the higher of the two levels takes effect. A Release core set to Trace still emits only Error and Critical. - Before the first
UC_SetLogLevel,UC_GetLogLevelreturns the compiled level (UC_LogLevel_Criticalwhen logging is compiled out). - A failed check (
KRISTENI_CHECK) bypasses the threshold: it always arrives as Critical with category"Check". - Neither function requires
UC_Initialize, and the level survivesUC_Shutdown; it belongs to the logger, not the core, just like the callback.
The callback is invoked synchronously on the thread that called the UC_
function, from inside that call, before the function returns. There is no queue
or background thread. When the function returns, all of its log lines have
already passed through the callback. The callback must not throw or call
UC_SetLogCallback itself.
Failed checks
A failed KRISTENI_CHECK indicates a core bug, not rejected input. Valid input
must never reach one. If it nevertheless happens:
| Core | Behaviour |
|---|---|
| Release | A Critical "Check" line is emitted; the check throws Kristeni::CheckError, the boundary Guard catches it, emits an Error "API" line with the function name, and returns UC_Result_InternalError. The process remains alive; the state of the house where the failure occurred is not guaranteed. |
| Debug | A Critical "Check" line is emitted, followed by abort() so the process stops in the debugger. K_CHECK exists only in Debug and always stops. |
Houses and handles
UC_CreateHouse returns a house handle. UC_DeleteHouse deletes the house,
including its graph and everything in it, stories included.
A house has no world-space transform: story elevations are relative to the house origin, while wall and slab points are relative to their story elevation; meshes use the same frame. The frontend decides where the house is placed.
A handle is a pair consisting of an index and a generation. A deleted object is not resurrected when another object takes its slot because their generations do not match.
Every command operating on a story, wall, opening, or slab also receives its
house. Their handles carry the house identity as well, in the upper 32 bits of
both the index and the generation, and the core verifies that the two match. A
handle belonging to another or deleted house returns
UC_Result_InvalidArgument; it never silently edits another house.
[!important] Handles are opaque to consumers. Store and return them exactly as received; never calculate or assemble them manually.
Stories
Since ABI 25 (House/APIStory.h, docs/integration.md D4), every wall and slab
belongs to exactly one story, supplied at creation: Story in
UC_WallRequest and UC_SlabParameters. A house does not create a story
automatically. After UC_CreateHouse, the frontend calls UC_CreateStory
before the first wall or slab. Restoration order is house → stories → walls →
slabs → openings → joinery.
| Function | Behaviour | Affected walls |
|---|---|---|
UC_CreateStory(House, ElevationCm, &Story) | Creates a story at an elevation relative to the house origin | None |
UC_UpdateStory(House, Story, ElevationCm) | Sets a new elevation; rebuilds nothing | None returned |
UC_DeleteStory(House, Story) | Cascades deletion through the story's walls (and their openings) and slabs | None returned |
The elevation may be any finite number, positive or negative; two stories at
the same elevation are still distinct stories. NaN and infinity return
UC_Result_InvalidArgument (even when the core is not initialized, because
this validation happens first).
Elevation is not part of the geometry. Input and mesh coordinates,
Origin and vertices, are relative to story elevation; the core never adds
the elevation. The frontend attaches meshes to the story object (an actor in
Unreal), which carries the elevation. Consequently, UC_UpdateStory returns no
affected walls: meshes remain byte-for-byte identical, and the frontend moves
only the story object. The core has no story-height property; height belongs to
the wall and is supplied in its request.
Boundary rules:
UC_BuildWallandUC_BuildSlabwithout a live story in the same house (a zeroStory, a deleted one, or one from another house) returnUC_Result_InvalidArgument; nothing is created, andOutWall/OutSlabremain zero. An ABI 24 request copied with a zeroStoryis rejected; there is no default story.UC_SetWallParameters(UC_WallParameters.Story) andUC_SetSlabParameterssupplied with a story different from the one used at creation returnUC_Result_InvalidArgument, with a zero count and an unchanged graph. Walls and slabs cannot move between stories; changing story requires deletion and reconstruction.- Intersections do not cross stories: the end of a wall on one story and the
start of a wall on another do not form a corner, even if they occupy the same
point and both stories have the same elevation.
UC_IntersectWallsrejects walls on different stories (see below). UC_DeleteStoryreturns no list. Other stories remain unchanged, and the frontend already knows the deleted objects: everything it created with that story. Handles for deleted walls, openings, and slabs immediately returnUC_Result_InvalidArgument, as does the story handle itself, including on a second deletion attempt.
Commands and affected walls
Mutating commands (creation, editing, intersection, and deletion) return the affected walls in a caller-owned array: every wall whose mesh must be requested again. The list includes the wall itself and neighbours whose joints have changed.
UC_StoryHandle story;
UC_CreateStory(house, 0.0f, &story); /* once, before the first wall */
UC_WallRequest request = {house, story, {0, 0, 0}, {400, 0, 0}, 25.0f, 280.0f};
UC_WallHandle wall;
UC_WallHandle affected[64];
uint32_t count = 0;
if (UC_BuildWall(&request, &wall, affected, 64, &count) == UC_Result_Success)
{
for (uint32_t i = 0; i < count; ++i)
{
/* UV mapping for each wall is owned by the frontend. */
const void* data = NULL;
uint32_t size = 0;
UC_GetWallMesh(house, affected[i], &left_uv[i], &right_uv[i], &data, &size);
/* Copy data immediately; see “Meshes”. */
}
}
Array rules:
- The count is always written and reports the required capacity, not the number of elements actually written.
- A zero capacity with
NULLis a valid request. UC_Result_Overflowfrom a mutating command does not mean “nothing happened”: the graph has already changed. Do not call the command again; a second creation would create a second wall. Refresh every wall in the house for this call, and allocate the capacity reported by the count next time.- The new object's identity is returned separately (
OutWall,OutOpening, orOutSlab) and is written before capacity is checked. The caller therefore receives the new handle even when the result isUC_Result_Overflow.
A wall has two distinct ends only when they are more than 0.05 cm apart,
the tolerance below which the graph merges points. UC_BuildWall with closer
StartPosition/EndPosition values and UC_SetWallParameters with a Length
of 0.05 cm or less return UC_Result_InvalidArgument before modifying the
graph: nothing is created, and the wall remains unchanged. This is the only
geometric validation performed at the boundary (ABI 22). Openings are not
validated either at the boundary or in the graph; see “Openings and joinery”.
The graph validates slab contours.
For read operations such as UC_GetWallOpenings, UC_Result_Overflow simply
means “allocate more space and call again”.
Intersections rejected by the graph
UC_IntersectWalls extends or shortens both walls to the point where their
centre lines intersect in plan, moving the closer endpoint of each wall. Since
ABI 24 the graph rejects three cases; since ABI 25 it rejects four. A rejection
returns UC_Result_InvalidArgument with a zero count and leaves the graph
unchanged. Both wall meshes remain byte-for-byte identical, so nothing needs to
be refreshed.
| Reason | Condition |
|---|---|
DifferentStories | The walls belong to different stories (ABI 25), even if those stories have the same elevation. This is checked first. |
Parallel | The centre lines are parallel or collinear in plan, so there is no single intersection point. This also covers a wall whose endpoints lie directly above one another. |
DifferentLevels | The endpoints are not at the same elevation: any of the four differs in Z by more than 0.01 cm. A sloped wall is rejected as well. |
TooFar | An endpoint would need to move in plan by more than twice the length of its wall, which is the near-parallel case. Shortening never reaches this limit. |
There are no separate result codes for these cases (lead decision dated 08.10).
The reason is available through UC_GetLastError (ABI 25), including its name,
and is identical in Debug and Release. The logger also receives a line at
UC_LogLevel_Warning with category "API"; a Release core does not emit
Warning (see “Logging”), so the line is absent there, but the error text remains
available.
The same result is returned when a wall is intersected with itself or when a wall is not live in the supplied house.
Meshes
A mesh is built on request from the graph and the supplied UV mapping:
| Function | UV mapping |
|---|---|
UC_GetWallMesh | Left and right faces |
UC_GetSlabMesh | Top, bottom, and edge |
UC_GetJoineryMesh | Included in the type parameters |
Passing NULL for UV mapping selects the default: scale 1, no rotation, and no
offset.
The buffer is owned by the core and remains valid until the next call on the same thread. The caller must copy it immediately; no cross-boundary release function is provided.
Read the “KMSH” block (Types/APIMesh.h) from beginning to end:
UC_MeshHeader: theKMSHsignature, version 3, surface count, and frame relative to story elevation: origin, rotation around Z, and wall length. The frontend (the story object) adds the elevation, not the core.- For each surface:
UC_SurfaceHeader, followed by vertices (threefloatvalues each), vertex instances (UC_MeshVertexInstance: normal, tangent, UV), and indices (uint32_t, three per triangle).
Vertices are expressed in the block's frame. The surface number (Side)
identifies the part: left and right for a wall; top, bottom, and edge for a slab;
frame, sash, and glass for joinery.
Empty meshes
A node with no surface is not an error (ABI 23). A wall whose entire face is cut
away by an opening, and a slab whose contour cannot be triangulated or is fully
consumed by its openings, continue to exist and retain their handles; the wall
also continues to trim its neighbours. UC_GetWallMesh and UC_GetSlabMesh
return UC_Result_Success and a block of exactly sizeof(UC_MeshHeader), which
is 36 bytes:
| Field | Value |
|---|---|
Magic, Version | As always: KMSH, 3 |
SurfaceCount | 0 |
Origin, RotationYaw, Length | The node's values, as in a full mesh (RotationYaw and Length are zero for a slab) |
A surface with no triangles is omitted completely; the block never contains a surface with zero counts. Therefore:
- Always read the surface count from
SurfaceCount; do not assume it. At present, a wall returns two surfaces or none. The one-surface case (one empty face) currently returnsUC_Result_InternalError(B-67). Once fixed, a one-surface block will also be possible, so code must continue to readSurfaceCount;Sideidentifies which surface was returned. - An empty block means “render nothing”: the frontend removes the object's old
mesh but retains the object and its handle. After
UC_DeleteOpeningor another edit, the same handle may return surfaces again. - A face split into pieces by an opening remains one surface with multiple islands: triangles that do not share a vertex.
Openings and joinery
An opening is a resource with its own handle: it survives movement,
intersection, and wall-thickness changes, and is destroyed with its wall.
UC_GetWallOpenings returns a wall's openings in creation order.
The opening is cut from the wall face, and nothing checks whether it lies
inside that face. Its rectangle may extend beyond the face, split it in two
horizontally or vertically, or cover it completely. All of these return
UC_Result_Success with an opening handle; the last case produces an empty mesh
(see “Empty meshes”). Shortening a wall so it no longer reaches its opening is
also a successful edit: the opening remains live and returns to the face once
the wall reaches it again.
One case is unsupported: an opening vertex in an intersection zone, between the trimmed edges of the two faces. The resulting geometry is incorrect; until this is fixed, the frontend must prevent such openings.
Joinery has no handle. Its type and parameters belong to the frontend, while
UC_GetJoineryMesh builds the mesh from the opening and those parameters: width
and height come from the opening, while placement and rotation come from the
wall. The frontend must therefore request the joinery mesh again after every
command that changes its wall.
Depth across the wall currently does not come from the wall:
| Type | Frame thickness | Frame width | Across the wall, relative to the frame |
|---|---|---|---|
| Windows (single-sash, double-sash) | ThicknessCm | FrameWidthCm | Centred: −ThicknessCm/2 … +ThicknessCm/2 |
| Door | 10 cm; ThicknessCm is ignored | 3 cm; FrameWidthCm is ignored | At the +Y face: WallWidth/2 − 10 … WallWidth/2 |
ThicknessCm = 0 is accepted, but “through the entire wall” is not implemented:
the window has zero depth. MullionWidthCm = 0 is also accepted, but does not
produce overlapping sashes. The mullion has zero width, and the glass is not
split by the mullion in any case.
The mesh frame begins at the opening's start point on the wall centre line,
shifted by AxisOffsetCm along +Y. In joinery-local coordinates (X along the
wall from the opening start, 0 … Length; Y across the wall, with +Y at the left
face; Z up), mirroring works as follows:
Side | PanelMirror | Point (x, y, z) becomes | AxisOffsetCm |
|---|---|---|---|
| Left | 0 | (x, y, z) | +offset |
| Right | 0 | (x, −y, z), mirrored across the centre line | −offset |
| Left | 1 | (Length − x, y, z), mirrored across the opening midpoint | +offset |
| Right | 1 | (Length − x, −y, z), rotated 180° around the vertical axis through (Length/2, 0) | −offset |
The single-sash window applies neither Side nor PanelMirror to its mesh. The
API nevertheless reverses the offset for Side = Right, so the window moves to
−offset without mirroring (its current box is symmetrical, so the shape does
not differ). This is a known limitation, not a contract.
Boundary rules
- No C++ classes, STL containers, or exceptions cross the boundary.
- Output parameters are zeroed on failure, so the caller may read them even then.
- Every failure leaves a reason in
UC_GetLastError; every success clears it. - Numeric validation happens before core initialization is checked: invalid
numeric input returns
UC_Result_InvalidArgumenteven when the core is not initialized. - A Python
ctypeswrapper usesc_int32forUC_Result,c_uint64for handle fields, andc_floatfor coordinates and dimensions. - On Windows, symbols are exported with
__declspec(dllexport). On GCC and Clang, internal symbols are hidden and C symbols are visible.
Tests
Source/API/Tests contains three kinds of tests:
CApiTests.cpp: boundary behaviour, including lifecycle, houses, stories, walls, openings, joinery, slabs, and byte-by-byte decoding of the “KMSH” block as the frontend sees it.CApiHeadersCompileTest.c: verifies that the headers compile as C and that structure sizes match expectations.ApiGuardTests.cpp: tests the boundary guard itself (ApiGuard.h).Kristeni::CheckErrorand every other exception becomeUC_Result_InternalError, with a reason in the thread-local message (ApiLastError.h); success clears it, and a failure without a specific reason receives at least its result-code name. This is the only test here that includes an internal header. A failed check must be unreachable from valid input through the C functions, so the guard is tested directly.