Skip to content

Latest commit

 

History

42 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

kSTEP

kSTEP

Type-safe Kotlin DSL for the STEP standard (ISO 10303).

Kotlin 2.4.10 Java 21+ Apache 2.0 CI

Warning
kSTEP is in early, pre-release development (M2). The API is unstable and no artifacts are published yet. See Status below for what actually works today.

Licensed under the Apache License, Version 2.0. See LICENSE.

Why kSTEP

LLMs are strong in popular, high-resource languages (Kotlin, Java, Python) but measurably weak at generating external, low-resource DSLs — and STEP’s native exchange format (Part 21, graphs of #123-style entity references) is a particularly hostile one to read or write directly.

kSTEP applies Typed Domain Grounding (TDG) — the pattern already validated by its sibling project, kUML: expose a domain as a type-safe, embedded Kotlin DSL with semantic validation and structured errors, so that both human developers and LLM agents can work in it reliably. The syntax stays Kotlin (high-resource); only the vocabulary is domain-specific, enforced by the type system. A strict type system, a compiler, and structured validation errors form a feedback loop the LLM can act on directly, without a separate benchmark or fine-tuning step. kSTEP compiles that DSL down to the neutral, standardized ISO 10303 exchange format.

Vision

kSTEP is not "just another CAD tool." The ambition is to cover the entire STEP standard (ISO 10303) — CAD is the entry point, not the boundary. STEP spans far more than mechanical geometry: manufacturing (STEP-NC), electrical and electronics (assemblies, PCBs, wiring), kinematics, composites, FEM/simulation, and plant/process data are all part of the standard, each carved out as its own Application Protocol (AP242, AP238, AP210, AP209, and others). Those APs are the building blocks kSTEP intends to grow into over time.

The CAD market today splits into two camps: classic CAD suites (SolidWorks, CATIA, Siemens NX, Fusion 360, Inventor, Onshape) — GUI- centric, click-based, proprietary formats, AI bolted on after the fact — and code-first CAD (OpenSCAD, CadQuery, Build123d, Replicad, JSCAD) — scriptable and Git-friendly, but niche and without real AI integration. kSTEP is the deliberate third way: code-first like the second camp, AI-first like neither camp, and STEP-native instead of proprietary. It is the same thesis as its sibling project kUML in the UML/SysML space, applied across the full breadth of engineering rather than just software architecture — kUML covers the IT domain, kSTEP covers the engineering domain, both grounded in the same Typed Domain Grounding (TDG) principle described above.

V1 deliberately starts narrow — a single Application Protocol (AP242), scoped down to product structure and metadata with no geometry and no PMI (see Roadmap below and kSTEP-ADR-0001) — but the architecture is meant to carry the wider vision from the start, not to box it in.

Status

The project has completed M1 (Headless MVP — EXPRESS parser, semantic model, Kotlin code generation, WHERE-rule evaluation, the kstep-core runtime DSL surface, STEP Part 21 export/import, and DERIVE/INVERSE/ UNIQUE clause capture, six waves) and has started M2 (AI Layer) with an MCP server exposing the M1 DSL as LLM tool-calling tools (M2 Welle 1, below). Separately, the long-planned geometry milestone has now started too (Geometrie Welle 1 — a new kstep-geometry module bridging to OpenCASCADE Technology (OCCT) via JNI; see the kstep-geometry row below and docs/adr/ADR-0005-occt-jni-bridge.adoc), joined by Geometrie Welle 6, pulled forward — a new kstep-constraints module bridging to the PlaneGCS 2D geometric constraint solver via JNI; see the kstep-constraints row below and docs/adr/ADR-0006-planegcs-constraint-bridge.adoc. Concretely, as of this writing:

  • An ANTLR4-based parser for the EXPRESS schema language (ISO 10303-11) exists and successfully parses a hand-written AP242-subset fixture in kstep-tests, plus (M1 Welle 4) a small excerpt of the real, official AP242 schema containing the six V1 entities and their supporting declarations — see below.

  • A semantic model walks the ANTLR parse tree into a typed Kotlin AST (dev.kstep.express.semantic): entities with their explicit attributes (declared type, optional-ness), supertype/subtype relationships, and WHERE-rule clauses. Attribute types resolve against a per-schema, case-insensitive symbol table built in a two-pass walk, so forward references (an entity referencing an entity declared later in the same schema) resolve correctly. WHERE-rule bodies are captured verbatim as unparsed source text.

  • A KotlinPoet-based code generator (dev.kstep.express.codegen.ExpressKotlinCodeGenerator) turns the semantic model into idiomatic Kotlin data class`es, one per entity, with named (and, for `OPTIONAL attributes, defaulted) constructor parameters mirroring the EXPRESS declaration order. Verified end-to-end against all six entities of the AP242-subset fixture, and (M1 Welle 4) against the real six V1 entities extracted from the official AP242 schema — see below. SUBTYPE OF is handled by attribute-inheritance flattening, not Kotlin inheritance (see the SUBTYPE OF inheritance-flattening entry below); constructs the generator still doesn’t turn into Kotlin — redeclared attributes, LOGICAL/NUMBER/BINARY-typed attributes, and zero-attribute entities — raise a structured CodeGenException instead of emitting a silently wrong or partial class. A TYPE reference (DefinedTypeRef) resolves only the single-level simple-alias case (TYPE x = STRING; and the other five simple EXPRESS types — ExpressDefinedType’s `underlyingSimpleType) to its underlying Kotlin type; anything needing further indirection — an aggregation, a SELECT/ENUMERATION, or a TYPE that itself references another TYPE — still raises CodeGenException, deliberately not resolved transitively (M1 Welle 4).

  • (M1 Welle 4) The generator has now run against real AP242 ground truth for the first time, not just the hand-written AP242-subset fixture. A curated excerpt of the official ap242ed2_dis2_mim_lf_v1.101.exp (ISO TS 10303-442 AP242 EXPRESS MIM Long Form, v1.101, 2019 — see NOTICE for full provenance, which parts are verbatim vs. deliberately adapted, and the precedent this excerpt’s inclusion rests on) — the six V1 entities plus the supporting TYPE/ENTITY declarations their attribute types reference, directly or (as of the SUBTYPE OF inheritance-flattening wave below) via SUBTYPE OF — is vendored at kstep-express/src/main/resources/dev/kstep/express/codegen/ap242-v1-entities.exp and regenerated by dev.kstep.express.codegen.Ap242V1CodeGen (see the generateExpressKotlin Gradle task below). The complete, unmodified official schema (49,942 lines, 2,122 entities) is not vendored in this repository — this wave’s real-schema verification is scoped to this excerpt, not a full-schema-scale exercise of the parser/semantic model. Against this real-schema ground truth, codegen now succeeds for all six V1 entities, including next_assembly_usage_occurrence — see the SUBTYPE OF inheritance-flattening entry below for how.

  • SUBTYPE OF attribute-inheritance flattening (M2, following M1 Welle 4’s real-schema cross-check above): dev.kstep.express.semantic. InheritanceResolver flattens a SUBTYPE OF chain into a ResolvedEntity — every ancestor’s explicit attributes prepended (supertype-most-general first, matching STEP Part 21 instance encoding) to the entity’s own — because a generated Kotlin data class cannot itself extend another data class; Kotlin inheritance was considered and rejected in favor of this flattening approach (see ExpressKotlinCodeGenerator’s KDoc for the full rejected-alternatives writeup). An `ABSTRACT SUPERTYPE contributes attributes to its subtypes but is never itself emitted as a Kotlin class. Structured failures (SemanticModelException): a SUBTYPE OF cycle, a chain deeper than 32 levels, an unresolved supertype name, more than one SUBTYPE OF ancestor (EXPRESS AND/ANDOR multiple inheritance, out of scope for V1), a SELF...RENAMED redeclared attribute encountered while flattening, and a flattened property-name collision between two different ancestors. Against real-schema ground truth, next_assembly_usage_occurrence’s actual SUBTYPE OF chain is `product_definition_relationshipproduct_definition_usageassembly_component_usagenext_assembly_usage_occurrence; none of the first three declare ABSTRACT SUPERTYPE in the real schema, so per EXPRESS semantics they are themselves instantiable too — but nothing in the six V1 entities' generated Kotlin references them as an attribute type, so Ap242V1CodeGen deliberately does not emit Kotlin classes for them (see that file’s SUPPORT_ENTITY_NAMES comment). NextAssemblyUsageOccurrence’s flattened constructor is `id, name, description?, relatingProductDefinition, relatedProductDefinition, referenceDesignator? (6 parameters). This also incidentally resolves the two entities flagged as a limitation in M1 Welle 4: product_context/product_definition_context are themselves SUBTYPE OF (application_context_element), and once that chain flattens cleanly they codegen too — so Product’s and `ProductDefinition’s generated Kotlin is now fully self-contained, with no dangling class references anywhere in the emitted file (see `Ap242V1CodeGenTest).

  • A real correctness fix from this cross-check: the real AP242 MIM’s approval.level attribute is typed label (a TYPE label = STRING; alias), not INTEGER as Welle 3 had assumed without a real schema to verify against. ap242-subset.exp’s `approval entity and kstep-core’s `Approval/ApprovalBuilder now use String; the SELF.level >= 0 WHERE rule (meaningless for a string) is replaced with SELF.level <> '', mirroring the non-empty-string pattern already used by product.id/product_definition.id.

  • A WHERE-rule evaluator (dev.kstep.express.validation) interprets the actually-occurring subset of EXPRESS WHERE-rule expressions: comparisons (>, >=, <, , =, <>), SELF.attribute references (and the equivalent bare attribute form) resolved against an instance attribute value bag, string/integer/real literals, and AND/OR/NOT boolean combinators. WhereRuleExpressionBuilder re-parses the verbatim expression text via a new ExpressParserFactory.parseExpression entry point and walks the expression parse tree into a small AST; WhereRuleEvaluator evaluates that AST against a Map<String, WhereRuleValue>; WhereRuleValidator ties both together into a (entityName, rules, attributeValues) → List<WhereRuleViolation> API. Anything outside the supported subset (EXISTS(), SIZEOF(), other function calls, aggregate/set operations, QUERY, arithmetic operators, the tri-state LOGICAL type, …​) raises a structured UnsupportedWhereExpressionException instead of silently doing the wrong thing; a genuine evaluation-time problem (a missing attribute, a non-boolean result, an incompatible-type comparison) raises WhereRuleEvaluationException. Both the re-parse and the AST walk are depth-guarded against pathologically deep (but syntactically valid) expressions, mirroring the existing StackOverflowError guard at the ANTLR-parse boundary and the MAX_TYPE_NESTING_DEPTH guard in the semantic model. The ap242-subset.exp fixture now carries a WHERE rule on five of its six entities (product_definition_formation deliberately has none), exercised end-to-end from parse through evaluation.

  • kstep-core now has hand-authored, type-safe Kotlin builders for all six V1 entities (dev.kstep.core.ap242): product, personAndOrganization, approval, productDefinitionFormation, productDefinition, nextAssemblyUsageOccurrence. Each is a named-parameter, lambda-with-receiver DSL function returning dev.kstep.core.ValidationResult<T>Valid(value) or Invalid(violations), never a thrown exception for a validation failure. Building an instance runs the WHERE-rule evaluator against the built values and also checks that every mandatory entity-typed reference attribute and every mandatory primitive attribute was actually set, collecting all violations (never stopping at the first) as structured DslViolation`s with `KSTEP-W-001 (WHERE rule not satisfied), KSTEP-M-001 (missing mandatory reference), or (M2 Welle 7) KSTEP-M-002 (missing mandatory primitive attribute) codes, analogous to kUML’s KUML-E-xxx structured errors — see Roadmap above for what KSTEP-M-002 covers and what it deliberately doesn’t. These six types are hand-authored independently of ExpressKotlinCodeGenerator’s generated output, and (M2 Welle 8) that relationship is now precisely characterized rather than a vague "future work" placeholder: (i) codegen already produces schema-faithful equivalents of all six types plus their six support entities in the generated `dev.kstep.generated.ap242v1 package (see Status above and Ap242V1CodeGenTest) — the generator needed no changes; (ii) kstep-core is a deliberately ergonomic layer aligned to the ap242-subset.exp fixture, which simplifies the real excerpt in specific, now-enumerated ways (entity references modeled as String, a few real attributes omitted, one optionality narrowed, one attribute invented, several WHERE rules synthesized — see each type’s KDoc in dev.kstep.core.ap242 for the per-attribute rationale); (iii) this wave adds dev.kstep.tests.Ap242CoreSchemaConsistencyTest, which re-derives the real shape live from ap242-v1-entities.exp on every run and fails the build on any divergence not explicitly named in its allowlist — so the gap is now bounded and guarded, not open-ended. See Roadmap for the full divergence inventory and what remains deliberately deferred.

  • (M2 Welle 10 — kstep-core rebuilt on the codegen-generated types) The fork M2 Welle 9 left open (extend the hand-authored layer with LIST/EXISTS() support, or rebuild it on top of the generated types) is resolved: kstep-core no longer hand-authors any AP242 entity shape at all. Ap242V1CodeGen.CORE_MODULE_OPTIONS generates all twelve entities (the six V1 types plus the six support entities their shapes require — application_context, product_context, product_definition_context, approval_status, person, organization) directly into kstep-core’s own compiled output, `internal constructor + @ConsistentCopyVisibility, wired via a Gradle consumable/resolvable configuration pair (not a cross-project sourceSets reach-through). dev.kstep.core.ap242 now supplies exactly one validating builder function per entity on top. Eleven of the M2 Welle 8 divergence-table’s thirteen entries are resolved outright; EXISTS(<attribute>) WHERE-rule support was added specifically for person.WR1, the one genuinely real, evaluable rule in the schema slice once person is modeled; a new KSTEP-A-001/AGGREGATION_BOUND_VIOLATED code distinguishes "an aggregation was never set" from "set, but empty" for every real [1:?]-bounded aggregation in the schema slice: product. frame_of_reference : SET [1:?] (mandatory) and person’s `middle_names/prefix_titles/suffix_titles : OPTIONAL LIST [1:?] (each individually optional, but non-empty once assigned at all). Full rationale, the resolved divergence table, and the accepted consequences (a real ergonomic regression on product/productDefinition, which now need an explicit context built first; the Part-21/MCP breaking changes) are in docs/adr/ADR-0004-core-on-generated-types.adoc.

  • (M2 Welle 7) A correctness fix in the same spirit as the approval.level one above: Product.kt’s doc comment claimed `id, name, description, all STRING, none OPTIONAL, which contradicted the real AP242 MIM (description : OPTIONAL text;, ap242-v1-entities.exp line 138) and the builder’s own description handling — corrected to state plainly that only id/name are non-OPTIONAL. This wave also closes the mandatory-primitive-attribute-presence gap: product.name and next_assembly_usage_occurrence.name are non-OPTIONAL label attributes with no WHERE rule, previously left silently defaultable to "" by both kstep-core’s builders and `kstep-mcp’s `build_product/build_next_assembly_usage_occurrence tools (which used to write name = args.name ?: ""). Both now use a nullable presence sentinel and surface KSTEP-M-002 — including through the MCP tool path, since that is exactly the "compiler as oracle for the LLM" surface this project exists to demonstrate — when name is never assigned. An explicitly empty name is unaffected and stays Valid.

  • (M1 Welle 5) kstep-step21 now has a real STEP Part 21 (ISO 10303-21 physical file exchange format) writer and reader for the six V1 AP242 entities, in dev.kstep.step21. Part 21 is a different, much simpler ISO 10303 sub-format than EXPRESS (the physical #123=ENTITY_NAME(…​); exchange syntax, not a schema language), so this wave deliberately does not reuse the ANTLR EXPRESS grammar/parser — it is a small, independent, hand-rolled scanner (Part21Tokenizer) plus a two-pass graph resolver (Part21GraphResolver). Part21Writer.write(header, roots) serializes a graph of already-validated kstep-core instances, deduplicating shared references by object identity (not structural equality) so a single shared Product gets exactly one #N no matter how many ProductDefinitionFormation`s reference it. `Part21Reader.read(source) parses Part-21 text back, resolving forward references (an entity may reference a #N defined later in the file) via an iterative, non-recursive topological sort, and reconstructs each instance through its kstep-core builder function — so WHERE-rule validation runs on read too, not only on write. Genuine structural malformation (missing semicolon, malformed #N=, an unknown entity name — under the default Part21ReadMode.STRICT; see the kstep-step21 row below for TOLERANT — wrong argument arity/kind, a reference pointing at the wrong target entity type, a dangling reference, a reference cycle, or a DoS-guard trip on source length/instance count/nesting depth/reference-chain depth) raises one of six structured exceptions (Part21SyntaxException, Part21EncodingException, Part21DanglingReferenceException, Part21CycleException, Part21LimitExceededException, Part21WriteException). A WHERE-rule failure while reconstructing a parsed instance is not thrown — it surfaces as a DslViolation in the returned Part21ReadResult.violations, with any instance that (directly or transitively) depended on a failed instance recorded in Part21ReadResult.skipped instead of being attempted, mirroring kstep-core’s own "a validation failure is structured data, not a thrown exception" philosophy. V1 does not implement ISO 10303-21’s `\X\/\X2\/\X4\ non-ASCII escape mechanism — string values are scoped to printable ASCII plus the ''-doubling convention for an embedded '; anything else raises Part21EncodingException rather than being silently mis-encoded. This includes a bare reverse solidus (\, 0x5C), rejected symmetrically on both sides: although it is itself printable ASCII, an unescaped \ is exactly what a conformant external Part-21 reader/writer would take as the start of an escape sequence it (unlike V1) does implement, so both Part21Writer (on write) and Part21Reader/Part21Tokenizer (on read, in either Part21ReadMode) reject it with Part21EncodingException rather than emit or silently mis-read a value whose meaning could change on the far side of the export boundary. Acceptance-criterion honesty: kSTEP-ADR-0001 names a lossless roundtrip through an external CAD/PLM tool (e.g. FreeCAD) as the real-world bar for Part 21 export/import. No such tool is available in this development environment, so that external validation has not been done — this wave’s acceptance test is a self-roundtrip only (Part21Reader.read(Part21Writer.write(header, model)) == model, verified in Part21RoundtripTest). This closes the kSTEP-own-format half of the ADR-0001 bar, not the external-interop half; see Roadmap below, which carries the FreeCAD/external-tool gap forward explicitly rather than overclaiming it.

  • kstep-step21 has no CLI wiring yet (no kstep render/kstep import command) — it is a library API only, callable from tests, matching how Wave 2’s codegen and Wave 3’s WHERE-rule evaluator were introduced as plain Kotlin APIs before any CLI wiring.

  • (M1 Welle 6) The semantic model now captures DERIVE, INVERSE, and UNIQUE entity-body clauses — a gap flagged across three separate wave reviews (ExpressEntity’s own "losslessly captured" KDoc was, until now, aspirational rather than true). Three new data classes in `dev.kstep.express.semantic: ExpressDerivedAttribute (name, declared type via the same parameterType resolution explicit attributes already use, verbatim initializer expressionText — DERIVE expressions are captured, not evaluated, exactly like WHERE rules), ExpressInverseAttribute (name, optional SET/BAG InverseAggregationKind + bounds, unresolved raw targetEntity, optional forEntity qualifier, forAttribute), and ExpressUniqueRule (optional label, verbatim referencedAttributes list, covering both bare and SELF\entity.attr-qualified forms). ExpressEntity gained matching derivedAttributes/inverseAttributes/uniqueRules fields, defaulting to empty lists exactly like whereRules when a clause is absent. DERIVE and UNIQUE assertions are cross-checked against the real product_definition, product_definition_formation, next_assembly_usage_occurrence, and person_and_organization entities in ap242-v1-entities.exp; no V1 entity has an INVERSE clause, so that capture is proven against a small, hand-written synthetic fixture instead. A redeclared (SELF\entity.attr) DERIVE or INVERSE name throws a structured SemanticModelException rather than silently dropping the clause or NPE-ing, mirroring mapParameterType’s existing precedent for out-of-scope constructs. Evaluation of `DERIVE/INVERSE/UNIQUE and ExpressKotlinCodeGenerator codegen support remain out of scope — this wave is capture only, exactly like WHERE-rule capture (Wave 2) preceded WHERE-rule evaluation (Wave 3).

  • (M2 Welle 1 — start of the "AI Layer" milestone) A new module, kstep-mcp (dev.kstep.mcp), exposes the six kstep-core V1 entity builders and kstep-step21’s Part 21 export as MCP (Model Context Protocol) tools over stdio, built on the official `io.modelcontextprotocol:kotlin-sdk-server:0.14.0 (MIT-licensed, see License and attribution below). Nine tools: build_product, build_person_and_organization, build_product_definition_formation, build_product_definition, build_next_assembly_usage_occurrence, build_approval, export_part21, list_entities, get_entity. Because every MCP tool call is stateless (JSON in, JSON out) but five of the six V1 builders take entity-typed Kotlin references (ProductDefinitionFormation.ofProduct: Product, etc.), kstep-mcp adds a session-scoped, in-memory EntityStore: each successful build_* call stores its validated entity under a caller-supplied id/handle (the entity’s own natural id where one exists — product, product_definition_formation, product_definition, next_assembly_usage_occurrence — or an arbitrary handle string for the two entities that don’t — person_and_organization, approval), and later calls reference earlier ones by that string. The store is scoped to one Server process for its lifetime (reset only on restart) — the kotlin-sdk’s own transport model (stdio, one process per session; `ChannelTransport supports multiple concurrent sessions against one Server, exercised directly in the test suite) offers no finer-grained session boundary worth adding complexity for at this wave’s scope. A ConcurrentHashMap backs the store, with put’s capacity check and insert made atomic under a single lock (a bare `size()-then-put() sequence would race under concurrent sessions); the store is capped at 512 entities and every string field/list is length/size-bounded, so a malformed or adversarial tool call cannot grow memory unboundedly or crash the server process. Every tool returns one of five structured CallToolResult error shapes instead of an opaque MCP protocol error — malformed_input, unknown_reference (an id/handle that was never built, or was built as the wrong entity type — collected for every bad reference in one call, not just the first), validation_failed (a lossless JSON mirror of kstep-core’s own `DslViolation list — the same structured "compiler as oracle" feedback loop a Kotlin caller already gets), store_capacity_exceeded, and export_failed — and no raw exception message or stack trace ever reaches the caller (every handler catches its own exceptions before the SDK’s own top-level handler, which does interpolate e.message verbatim, gets a chance to). Tested end-to-end (not just handler-level) through the SDK’s own ChannelTransport in-memory client/server transport (io.modelcontextprotocol:kotlin-sdk-testing, @ExperimentalMcpApi) — a real Client drives a real Server with all fifteen tools registered, including a full multi-tool-call build-and-export sequence whose Part 21 output is parsed back with kstep-step21’s own `Part21Reader and compared for equality, proving the MCP layer round-trips genuinely, not just that individual calls don’t crash. Explicitly out of scope this wave: any actual LLM API call or benchmark (README roadmap item 5’s second half — a separate, larger piece of future work), kstep-cli wiring (no kstep mcp command yet — runStdioServer() is fully wired and ready for a future one-line CLI subcommand to call), and any transport other than stdio.

  • (M2 Welle 3) kstep-cli is no longer a placeholder skeleton: kstep mcp starts the kstep-mcp server over stdio by calling its existing, unmodified runStdioServer(). No arguments, help, or --help print a short usage message and exit 0; an unknown subcommand (or mcp with extra trailing arguments) prints the same usage message and exits 1. The argument-dispatch logic lives in a pure resolveCommand(args: Array<String>): CliCommand function, kept deliberately separate from main()’s side effects (`println/exitProcess/runBlocking) so it’s directly unit-testable — main() itself calls exitProcess on the error path, which would tear down a test JVM if invoked in-process, so it is intentionally never called from a test. Tests live in kstep-tests (CliMainTest), not a kstep-cli-local test source set, matching this project’s one-test-module pattern (see Building below). Verified empirically (not assumed) what happens once runStdioServer() returns: at that point the JVM has exactly one non-daemon thread left (main, parked in runBlocking), so the process exits on its own with code 0 — no explicit exitProcess(0) needed on that path. Also verified: an immediate stdin EOF (e.g. < /dev/null) does not reliably make runStdioServer() return on its own — a kotlin-sdk-server:0.14.0 / StdioServerTransport behavior, not introduced by this wave and out of scope to fix here (would mean changing kstep-mcp’s own transport handling). In practice this matches how MCP hosts actually manage stdio subprocesses: SIGTERM/SIGKILL the child directly rather than relying on it observing a clean stdin close, and a SIGTERM does terminate the process immediately, verified the same way. No new CLI subcommand beyond `mcp, no argument-parsing library — a plain when over args is enough at this scope.

  • (M2 Welle 4) DERIVE-expression evaluation and UNIQUE-constraint enforcement now exist, building on M1 Welle 6’s capture-only clauses. dev.kstep.express.validation gained WhereRuleEvaluator.evaluateToValue (a thin additive entry point returning the raw WhereRuleValue an expression reduces to, without evaluate’s top-level boolean requirement) and a new `DerivedAttributeEvaluator, which re-parses an ExpressDerivedAttribute’s initializer text and evaluates it via the same `WhereRuleExpressionBuilder/WhereRuleEvaluator machinery WHERE rules already use — DERIVE’s initializer and WHERE’s domainRule are the identical expression grammar production, so this is deliberately not a second parser/evaluator. Verified against all three real DERIVE clauses in ap242-v1-entities.exp: product_definition’s and `person_and_organization’s (`get_name_value(SELF), get_description_value(SELF)) and next_assembly_usage_occurrence’s (a two-hop `SELF\entity.attr\entity.attr chain) all correctly throw UnsupportedWhereExpressionException — function calls and multi-hop qualifier chains are outside the supported subset, exactly like WHERE rules using the same constructs, and that is this wave’s expected, complete outcome for those three clauses, not a gap. A small synthetic fixture (DERIVE canonical_id : STRING := SELF.id; and similar) proves positive evaluation actually works when the expression is within the supported subset. INVERSE evaluation remains explicitly out of scope — no real V1 entity has an INVERSE clause, and kstep-core has no bidirectional-relationship modeling to evaluate against.

    Separately, kstep-mcp’s `build_next_assembly_usage_occurrence tool now enforces the real AP242 next_assembly_usage_occurrence UNIQUE UR1 rule — (reference_designator, relating_product_definition) must be unique across every next_assembly_usage_occurrence already in the EntityStore — comparing relating_product_definition by object identity (mirroring EntityStore.keyOf’s existing precedent for "no natural id" entity comparisons). A conflict returns a new structured `unique_constraint_violated tool error (added to McpToolError.kt, following the file’s existing shape/conventions) naming the rule, the conflicting id, and the duplicated field values, instead of silently allowing the duplicate or crashing. The rule constant and the scan logic live in kstep-mcp’s tool file, not in `kstep-core — UNIQUE is fundamentally cross-instance, and kstep-core’s builders are pure, single-instance constructors with no visibility into other instances; the `EntityStore is the only place in this codebase with that visibility. The scan is O(n) over the store’s current entries, bounded by the store’s existing maxEntities cap, so this does not introduce unbounded work. Two rules are deliberately not newly enforced, both documented in code and here rather than faked or approximated: NAUO’s own UNIQUE UR2 (product_definition_occurrence_id, relating_product_definition), because product_definition_occurrence_id is itself a DERIVE value chained through product_definition_occurrence, an entity nowhere modeled among kstep-core’s twelve AP242 types; and `product_definition_formation’s own `UNIQUE UR1 (id, of_product), because the EntityStore already keys every product_definition_formation by that same id, so the composite key can never actually collide without id itself colliding first — a claim proven empirically by a test (KStepMcpServerTest), not just asserted in prose. The UR1 scan and the store write run atomically under EntityStore’s existing `capacityLock (a new putIfNoConflict, alongside the plain put used by every other tool), so two concurrent, conflicting build_next_assembly_usage_occurrence calls can no longer both pass the scan before either lands.

  • (M2 Welle 6) A new module, kstep-script (dev.kstep.script), adds a Kotlin-scripting DSL surface: .kstep.kts scripts author kSTEP models with the same six kstep-core builders (available without explicit imports, via KStepScriptCompilationConfiguration’s `defaultImports) and end with a stepFile(fileName = "…​") { …​ } call whose result — a KStepModel — becomes the script’s return value. KStepModelBuilder.root(…​) accepts either an already-unwrapped entity (the concise getOrThrow() pattern) or a raw ValidationResult — the latter *aggregates every violation across every registered root into KStepModel.violations instead of aborting the script at the first bad entity, the preferred form for LLM/JSON consumption (kSTEP-ADR-0001 acceptance criterion #3). KStepScriptHost.eval (a File or inline String overload) compiles and runs a script and maps every outcome — never a thrown exception or a raw stack trace — into a structured KStepScriptOutcome: Success, CompilationError (KSTEP-S-001, a Kotlin syntax/type error, with source line/column), NoModelProduced (KSTEP-S-002, the last expression wasn’t a KStepModel), ValidationErrors (the aggregated DslViolation list, or — belt-and- braces — a single synthetic KSTEP-S-004 violation when a script uses getOrThrow() directly and it throws), and RuntimeError (KSTEP-S-003, any other script-thrown exception, exception class
    message only). A blank timestamp in stepFile(…​) is defaulted to the current time by the host (not the builder, keeping the DSL surface itself pure/deterministic) once a model is known to be fully valid. kstep-cli gained a new kstep export <script.kstep.kts> [--out <file.step>] [--output json] subcommand: --out defaults to the script’s own name with .kstep.kts replaced by .step; --output json renders the same success/error information as a JSON document instead of human-readable text (both are the command’s own stdout output, so both stay plain println, matching this module’s existing reasoning for USAGE_TEXT). resolveCommand’s argument parsing for `export is a small hand-rolled flag loop, no argument-parsing library, same stance as the mcp/help dispatch above. kstep-script is deliberately not sandboxed — KStepScriptCompilationConfiguration uses dependenciesFromCurrentContext(wholeClasspath = true) with no curated/allowlisted classpath, mirroring kUML’s trusted in-process script path (kUML’s sandboxed path exists only for its hosted-portal "compile someone else’s script" scenario, which kSTEP has no equivalent of yet — see KStepScriptHost’s KDoc for the full reasoning). Verified against the real `kstep-cli distribution, not just the test JVM: ./gradlew :kstep-cli:installDist followed by running the built bin/kstep-cli export binary against both fixtures below reproduces the exact JSON/text/exit-code behavior asserted in the test suite — kotlin-compiler-embeddable and the rest of the scripting toolchain ride along automatically on kstep-cli’s `runtimeClasspath (and so into installDist’s `lib/) via the ordinary implementation project(":kstep-script") dependency, no jlink/native-image wiring needed for this. Two fixture scripts (hello-assembly.kstep.kts, hello-invalid.kstep.kts) live in kstep-tests/src/test/resources — not under kstep-script itself, matching this project’s established single-shared-test-module convention (see Building below) — and double as the README usage examples below.

  • Headless preview rendering (see docs/adr/ADR-0011-headless-preview-rendering.adoc): kstep-cli gained kstep render <script.kstep.kts> [-f svg|png|text] [-o <file>] [-w <px>] [--height <px>] [--with-step] [--require-geometry] [--output json], writing a headless SVG/PNG/text preview of a script with no window and no display server. A new module, kstep-render, carries the mesh/projection/rasterizer code Viewer-Welle 1 introduced (moved out of kstep-viewer, which now depends on it) plus two new writers, TriangleSvgWriter (a deterministic vector render) and TextCardRenderer (the shared SVG/PNG text-card renderer both a no-geometry script and a not-renderable-this-run script fall back into) — all with zero Compose Multiplatform dependency, verified via ./gradlew :kstep-cli:dependencies --configuration runtimeClasspath. KStepModel gained a shapes: List<ShapeAssignment> field and a shape(…​) builder function (kstep-script now depends on kstep-shape), so a script can register an OcctShape for preview independently of root(…​)kstep export’s `Part21Writer output is completely unaffected. Never a silently-swapped file type: the requested/resolved container (SVG/PNG/.txt) is always honored, only the content varies. See the ADR for the full content/exit-code matrix and the two distinct "OCCT is unavailable" failure points this wave had to fall back from cleanly.

  • Asciidoctor pre-processing integration (see docs/adr/ADR-0019-kstep-asciidoc.adoc): a new kstep asciidoc subcommand pre-renders kstep preview blocks (a markdown fence, an AsciiDoc [kstep]/[source,kstep] delimited block, or a kstep::path[] block macro) in .adoc files into image:: references — mirroring kuml-dev/kUML’s `kuml-asciidoc pre-processing approach, since a JVM Asciidoctor extension cannot run inside Antora’s own Node.js pipeline. Two new modules: kstep-preview (the headless-preview pipeline extracted, unchanged in behavior, out of kstep-cli’s former `RenderCommand.kt, now shared by both kstep render and kstep asciidoc) and kstep-docs:kstep-asciidoc (the scanner/rewriter/file-tree processor itself, depending only on kstep-preview — never on kstep-cli). RenderExtractionParityTest pins the two callers' output as byte-identical subprocess-vs-in-process. Pure source move for RenderFormat/ PreviewSummary (dev.kstep.clidev.kstep.preview) — no published artifacts exist yet, so this is not a breaking change for anyone outside this repo.

See Roadmap for what’s planned next.

Modules

Module Purpose Current state

kstep-core

Core DSL types (dev.kstep.core.ValidationResult/DslViolation) and — as of M2 Welle 10 — the codegen-generated dev.kstep.generated. ap242v1.* entity data class`es themselves (compiled directly into this module, `internal constructors), plus one validating named-parameter builder function per entity under dev.kstep.core.ap242

Working: applicationContext/productContext/ productDefinitionContext/approvalStatus/person/organization/ product/personAndOrganization/approval/ productDefinitionFormation/productDefinition/ nextAssemblyUsageOccurrence builder functions (twelve total), each running WHERE-rule, mandatory-reference (KSTEP-M-001), aggregation-bound (KSTEP-A-001), and — (M2 Welle 7) product.name/next_assembly_usage_occurrence.name — mandatory- primitive-attribute (KSTEP-M-002) validation and returning a ValidationResult. Depends on kstep-express via api (not implementation): both the generated types it compiles and dev.kstep.express.validation’s `WhereRuleSpec/WhereRuleValue appear in this module’s own public builder signatures.

kstep-express

ANTLR4-generated EXPRESS parser (dev.kstep.express.ExpressParserFactory), semantic model (dev.kstep.express.semantic), Kotlin code generator (dev.kstep.express.codegen.ExpressKotlinCodeGenerator, built on KotlinPoet), real-schema six-V1-entity regeneration (dev.kstep.express.codegen.Ap242V1CodeGen), and WHERE-rule validation (dev.kstep.express.validation)

Working: parses EXPRESS source into an ANTLR parse tree (verified against a real-schema excerpt, not just the AP242-subset fixture); walks it into a semantic AST; generates Kotlin data class`es from that AST, including single-level simple `TYPE-alias resolution; evaluates the supported WHERE-rule expression subset against instance attribute values. (M2 Welle 4) Also evaluates DERIVE initializer expressions within that same supported subset, via DerivedAttributeEvaluator and the new WhereRuleEvaluator.evaluateToValue entry point — reusing the WHERE-rule builder/evaluator, not a second parser. The generateExpressKotlin Gradle task regenerates the six V1 entities from a real-schema extraction as a build artifact (wired into check, not into this or any other module’s own compile classpath — see Building below).

kstep-step21

STEP Part 21 (ISO 10303-21 physical file exchange format) reader/writer for the twelve AP242 entities (dev.kstep.step21)

Working: Part21Writer.write/Part21Reader.read, a hand-rolled scanner and two-pass graph resolver (independent of the ANTLR EXPRESS grammar — Part 21 is a different sub-format). Verified via a self-roundtrip test suite in kstep-tests; no external CAD/PLM tool (e.g. FreeCAD) validation yet — see Status above. (M2 Welle 6) Now wired into the CLI via kstep-script/kstep export — see the kstep-script/kstep-cli rows below. (Geometrie Welle 4) The value grammar and instance model now also cover real, foreign-produced AP242 output (integers/reals, enumerations, *, typed parameters, complex instances) behind Part21ReadMode.TOLERANTSTRICT (the default) is unchanged: an unknown entity name is still a hard Part21SyntaxException there. See the kstep-shape row below and docs/adr/ADR-0009-ap242-shape-assignment.adoc.

kstep-script

Kotlin-scripting DSL surface for *.kstep.kts scripts (dev.kstep.script)

Working (M2 Welle 6): KStepScript/KStepScriptCompilationConfiguration (the @KotlinScript template + defaultImports), stepFile { } / KStepModel / KStepModelBuilder (the DSL entry point a script ends with), and KStepScriptHost.eval (File or inline String), which maps every compile/runtime outcome into a structured KStepScriptOutcome — never a thrown exception. See Status above for the full breakdown. Deliberately unsandboxed (trusted in-process path only, wholeClasspath = true) — see Status above and KStepScriptHost’s KDoc. Depends on `kstep-core and kstep-step21 only (both api, so their types resolve inside scripts).

kstep-cli

Command-line entry point (dev.kstep.cli.MainKt)

Working (M2 Welle 3): kstep mcp starts the MCP server (kstep-mcp’s `runStdioServer()); no-args, help, and --help all print a short usage message and exit 0; an unknown subcommand exits 1. (M2 Welle 6) kstep export <script.kstep.kts> [--out <file.step>] [--output json] compiles and runs a *.kstep.kts script via kstep-script’s `KStepScriptHost and writes the resulting Part 21 file via kstep-step21’s `Part21Writer — see Status above and Usage below. Depends on kstep-mcp and kstep-script.

kstep-mcp

MCP server exposing the twelve AP242 entity builders and Part-21 export as LLM tool-calling tools (dev.kstep.mcp), built on the official io.modelcontextprotocol:kotlin-sdk-server

Working (M2 Welle 1, expanded M2 Welle 10): fifteen tools — build_application_context, build_product_context, build_product_definition_context, build_approval_status, build_person, build_organization, build_product, build_person_and_organization, build_product_definition_formation, build_product_definition, build_next_assembly_usage_occurrence, build_approval, export_part21, list_entities, get_entity — over stdio transport, with a session-scoped in-memory EntityStore (bounded, thread-safe) resolving entity-typed references by caller-supplied id/handle. No HTTP/SSE transport. Depends on kstep-core and kstep-step21 only. (M2 Welle 2) Server lifecycle and every tool call’s outcome are logged via kotlin-logging over an explicit slf4j-simple backend. (M2 Welle 3) kstep-cli now wraps runStdioServer() behind a kstep mcp subcommand — see the kstep-cli row above. (M2 Welle 4) build_next_assembly_usage_occurrence now also enforces NAUO’s real UNIQUE UR1 rule against the EntityStore’s current entries, returning a new `unique_constraint_violated structured error on conflict (tool count stays at nine — no new tool added).

kstep-geometry

OCCT (Open CASCADE Technology) JNI bridge — the start of the geometry milestone (dev.kstep.geometry, native/JNI internals in dev.kstep.geometry.occt)

Working (Geometrie Welle 1): OcctKernel.makeBox(dx, dy, dz) builds a real OCCT box; the resulting OcctShape exposes its unique B-Rep topology (ShapeTopology: solids/shells/faces/edges/vertices, via TopExp::MapShapes, not TopExp_Explorer) and volume, and writeStepFile(…​) exports a real AP242 (or AP203/AP214IS) STEP file through OCCT’s own STEPControl_Writer. (Geometrie Welle 5a) OcctKernel.extrudeProfile(profile, height) extrudes a closed 2D ProfilePoint polygon in the XY plane into a solid (BRepBuilderAPI_MakePolygonBRepBuilderAPI_MakeFaceBRepPrimAPI_MakePrism), and OcctKernel.fillet(shape, edgeIndices, radius) rounds one or more edges of an existing shape (BRepFilletAPI_MakeFillet), returning a brand-new, independently closeable OcctShape — the input shape is left untouched. Both operations compose with makeBox and with each other (a fillet result can itself be filleted again); see docs/adr/ADR-0008-occt-feature-operations.adoc for the DoS-guard measurements, edge-addressing caveats, and the native-side deadlock trap its own implementation had to avoid. (multi-shape-composition-and- fill-light wave) Placement (translation + rotationX/Y/Z, no scaling, no arbitrary matrix — rotation-block determinant +1 by construction, no runtime guard needed) and MeshComposition.merge(parts: List<PlacedMesh>): TriangleMesh flatten several placed meshes into one world-space mesh, guarded against OcctKernel.MAX_TRIANGLES before allocating; kstep-viewer’s demo scene is the first real caller. See docs/adr/ADR-0013-multi-shape-composition-and-fill-light.adoc. Depends on nothing but `kotlin-logging-jvm — deliberately not on kstep-core yet (see docs/adr/ADR-0005-occt-jni-bridge.adoc), and deliberately not on kstep-render either (PlacementPackageBoundaryTest enforces this  — Placement’s vector/matrix arithmetic is its own, not a reuse of `kstep-render’s internal `Vec3). The native shim (src/main/cpp/kstep_occt_bridge.cpp) is compiled by Gradle against a system-installed OCCT (linux-x86-64 only in this wave) and skipped entirely — not failed — when the OCCT dev headers are absent, in which case OcctKernel.availability() reports OcctAvailability.Unavailable at runtime with a human-readable reason. No OCCT binary/header/source is vendored in this repository; see NOTICE and the ADRs for the full license and provenance discussion, and Building below for the required apt-get install. (Geometrie Welle 5b, Teil 1) A new dev.kstep.geometry.feature subpackage adds a parametric-history foundation on top of the above, unmodified: Feature (sealed —  BoxFeature/ExtrudeFeature/FilletFeature), FeatureSequence, and FeatureRebuilder.rebuild(sequence) replay a sequence of OcctKernel calls and return a FeatureRebuildResult (all built shapes, not just the final one, all AutoCloseable together) — letting a caller change one Feature’s value (e.g. a `FilletFeature.radius via .copy(…​)) and rebuild to get a new, independently measurable result. Still opaque, construction-order-dependent fillet edge indices (unchanged from Geometrie Welle 5a); still no stable geometric edge identity, undo/redo, feature-tree UI, or serialization — see docs/adr/ADR-0015-parametric-feature-history-foundation.adoc for the full scope boundary and what remains for Geometrie Welle 5b’s continuation.

kstep-constraints

PlaneGCS 2D geometric constraint-solver bridge — Geometrie Welle 6, pulled forward, plus C-Welle 2/2c (dev.kstep.constraints, native/JNI internals in dev.kstep.constraints.planegcs)

Working: PlaneGcsSolver.solve(points, constraints) solves a 2D point system under a mixed set of SketchConstraint`s — `DistanceConstraint, CoincidenceConstraint, HorizontalConstraint, VerticalConstraint, PointOnLineConstraint (C-Welle 2, see docs/adr/ADR-0007-planegcs-additional-constraint-types.adoc), ParallelConstraint, PerpendicularConstraint (C-Welle 2c, see docs/adr/ADR-0014-planegcs-parallel-and-perpendicular.adoc) — through a real, vendored PlaneGCS solver core, returning each point’s solved position and a SolveStatus (SUCCESS/CONVERGED/FAILED/ SUCCESSFUL_SOLUTION_INVALID). PlaneGcsSolver.solveDistances(…​) remains as a convenience overload for the distance-only case, delegating to solve(…​). A single stateless native call per solve — no persistent handle, no close() — see docs/adr/ADR-0006-planegcs-constraint-bridge.adoc for why. Depends on nothing but kotlin-logging-jvm, same as kstep-geometry. The PlaneGCS solver sources are vendored (unlike kstep-geometry’s OCCT, which links a system package — PlaneGCS ships no distribution package at all) under `src/main/cpp/third_party/planegcs/ at a pinned upstream commit; see that directory’s PROVENANCE.adoc, NOTICE, and ADR-0006/-0007/-0014 for the full LGPL-2.0-or-later license analysis. The native shim (src/main/cpp/kstep_planegcs_bridge.cpp) is compiled by Gradle (linux-x86-64 only so far) and skipped entirely — not failed — when the Eigen/Boost dev headers are absent, in which case PlaneGcsSolver.availability() reports PlaneGcsAvailability.Unavailable at runtime with a human-readable reason. See Building below for the required apt-get install.

kstep-shape

AP242 shape assignment — Geometrie Welle 4, merges kstep-core product structure with kstep-geometry B-Rep solids (dev.kstep.shape)

Working: Ap242ShapeExporter.export(header, ShapeAssignment(productDefinition, occtShape)) writes the shape to a real temp STEP file, extracts its geometry subgraph (discarding OCCT’s own placeholder product structure —  see Ap242GeometryExtraction), and merges it with kSTEP’s own validated product structure into one ISO 10303-21 file via PRODUCT_DEFINITION_SHAPE/SHAPE_DEFINITION_REPRESENTATION. Deliberately 1:1 (one ProductDefinition, one OcctShape) this wave; no DSL/CLI/MCP surface yet. Depends on kstep-core, kstep-step21, and kstep-geometry (all api, since they appear in this module’s own public signatures) —  see docs/adr/ADR-0009-ap242-shape-assignment.adoc for the full design, the Part-21-level-merge-vs-XCAF decision, and the injection/DoS security analysis.

kstep-render

Headless, Compose-free mesh projection + SVG/PNG/text-card rendering —  headless-preview-rendering wave (dev.kstep.render.mesh/.image/.svg/ .text)

Working: dev.kstep.render.mesh.MeshProjection (moved here from kstep-viewer, package renamed to match; itself renamed from IsometricProjection in the viewer-camera-interaction wave once the camera stopped being fixed-isometric-only, see docs/adr/ADR-0012-viewer-camera-interaction.adoc) turns a TriangleMesh and a Camera (azimuth/elevation orbit pose, defaulting to Camera.ISOMETRIC for source- and behavior-compatibility with every pre-ADR-0012 call site) into a backface-culled, flat-shaded, painter’s-algorithm-sorted, canvas-fit List<ProjectedTriangle> — consumed identically by dev.kstep.render.image.TriangleRasterizer (plain AWT, promoted from a kstep-viewer test-only class), the new dev.kstep.render.svg.TriangleSvgWriter (a deterministic, byte- reproducible vector render — same coordinates rounded to 2 decimals, input order preserved, never re-sorted; unaffected by the multi-shape-composition-and-fill-light wave — see below), and the new dev.kstep.render.text.TextCardRenderer (renders a plain List<String> as an SVG or BufferedImage "text card" — the ONE renderer both a no-geometry and a not-renderable-this-run kstep render result share). dev.kstep.render.svg.SvgEscaping escapes every interpolated string these writers emit. RenderLimits centralizes the size/DoS bounds both this module’s writers and kstep-cli’s `render command validate against. Zero Compose dependency (enforced by the same automated package-boundary test kstep-viewer used to carry, now covering this whole module) — this is the entire reason the module exists separately from kstep-viewer: kstep-cli depends on it directly, and KStepScriptCompilationConfiguration’s `wholeClasspath = true would otherwise forward Compose/Skiko into every *.kstep.kts script’s own compile step. See docs/adr/ADR-0011-headless-preview-rendering.adoc. (multi-shape- composition-and-fill-light wave) MeshProjection’s shading formula gains a second, additive fill light on top of the existing `AMBIENT floor + key light — shade = (AMBIENT + KEY_WEIGHT * key_term
FILL_WEIGHT * fill_term).coerceIn(0.0, 1.0)
, AMBIENT/KEY_WEIGHT unchanged in value, the fill light dimmer than and deliberately not antiparallel to the key light, decomposed/recombined per camera pose exactly like the key light already was (ADR-0012). See docs/adr/ADR-0013-multi-shape-composition-and-fill-light.adoc. (gltf-glb-export wave) dev.kstep.render.gltf.GlbWriter turns a TriangleMesh into a self-contained, deterministic binary glTF 2.0 (.glb) document — non-indexed, flat shaded, one PBR metallic-roughness material, no textures, a Z-up-to-Y-up node rotation rather than a baked-in transform. Verified against the real Khronos gltf-validator (see scripts/, and Building below). This wave adds the module’s one new external dependency, kotlinx-serialization-json (already on kstep-cli’s runtime classpath transitively before this wave; now also declared directly here) — used to build the writer’s JSON document, deliberately not a handwritten escaper (correct JSON escaping of script-supplied `asset.extras text is a security property, not a style choice). See docs/adr/ADR-0016-gltf-glb-export.adoc.

kstep-preview

Shared headless-preview pipeline extracted from kstep-cli’s former `RenderCommand.kt — kstep-asciidoc wave (dev.kstep.preview)

Working: PreviewRenderer.render(PreviewRequest): PreviewOutcome is the ONE implementation of ADR-0011’s Container-Regel and Pflicht-Fallback — evaluate a *.kstep.kts script (from a file or inline source text), detect geometry, resolve the SVG/PNG/text/GLB container, render bytes, close every shape it triangulated, all with no file I/O, no exitProcess, no println. kstep render (kstep-cli) and kstep asciidoc (kstep-docs/kstep-asciidoc) both call it instead of each carrying their own copy — see RenderExtractionParityTest (kstep-tests), which pins the two callers' output as byte-identical. PreviewWriter is the small, separate piece that actually writes a Rendered outcome’s bytes to disk (only kstep-cli calls it — kstep asciidoc writes its own image tree via kstep-docs/kstep-asciidoc’s `AsciidocProcessor instead). RenderFormat/ PreviewSummary/RenderFallbackReasons moved here unchanged (only their package changed, dev.kstep.clidev.kstep.preview) from kstep-cli. See docs/adr/ADR-0019-kstep-asciidoc.adoc for why this module exists separately from kstep-render (so kstep-viewer never gains a kotlin-compiler-embeddable dependency through it).

kstep-docs:kstep-asciidoc

Pre-processing Asciidoctor integration — kstep asciidoc — kstep-asciidoc wave (dev.kstep.asciidoc)

Working: kstep asciidoc --input <f.adoc> --output <f.adoc> (or --input-dir/--output-dir for a whole tree) pre-renders kstep preview blocks into image:: references, exactly the way kuml-dev/kUML’s own `kuml-asciidoc pre-processes [kuml] blocks for Antora (a JVM Asciidoctor extension cannot run inside Antora’s Node.js/Asciidoctor.js pipeline — see the ADR’s Context section). Recognizes three block forms carrying identical semantics — a kstep markdown fence, an AsciiDoc [kstep,target,format]/[source,kstep] delimited block, and a kstep::path.kstep.kts[] block macro — via AsciidocScanner, a pure, I/O-free, script-free line scanner that leaves any OTHER AsciiDoc delimited/ comment block untouched (including this project’s own documentation, which demonstrates the syntax). AsciidocRewriter renders each recognized block through kstep-preview’s `PreviewRenderer and reassembles the document; AsciidocProcessor is the file-tree layer (symlinks skipped and reported, never followed or copied; --output-dir may not nest inside --input-dir or vice versa). Every macro/attribute path is checked against its containment root through two independent layers — lexical (normalize().startsWith(root)) and physical (toRealPath(), which also catches a symlink pointing outside the tree) — see AsciidocPaths and the ADR’s Security section. Two failure classes, two behaviors: an environment-side failure (OCCT unavailable, a shape that would not triangulate) degrades to a notice card in the same container, exit 0 (exit 1 with --require-geometry) — identical to kstep render’s own Pflicht-Fallback; an author-side failure (a script that fails to compile/ validate/run) aborts the whole document by default (--on-error fail`), or renders an error card and continues (--on-error card). Depends only on kstep-preview — never on kstep-cli (kstep-cli depends on THIS module to expose the subcommand; the reverse edge would be a build cycle, enforced by AsciidocModuleBoundaryTest). See docs/adr/ADR-0019-kstep-asciidoc.adoc.

kstep-viewer

Interactive OCCT shape viewer — Viewer-Welle 1 (dev.kstep.viewer.ui, mesh code now in kstep-render), camera orbit/zoom/reset added in the viewer-camera-interaction wave (dev.kstep.viewer.camera)

Working: a real Compose Desktop window (ShapeCanvas, opened via ./gradlew :kstep-viewer:run, never part of check) consumes dev.kstep.render.mesh.MeshProjection.project(…​) from kstep-render (see that module’s row above — this code lived here until the headless-preview-rendering wave moved it out so kstep-cli could reuse it without a Compose dependency). No longer a single static isometric snapshot: drag to orbit, scroll to zoom, double-click or R to reset, a bottom-centered hint label shown only at the untouched home pose. dev.kstep.viewer.camera.ViewerCameraState/CameraInteraction hold the pure (Compose-free) orbit/zoom math the pointer/keyboard handlers call into; ShapeCanvas’s `state parameter is externally hoistable (defaults to an internal remember), so a future toolbar can drive the camera without a ShapeCanvas API change. See docs/adr/ADR-0010-occt-triangulation-and-viewer.adoc for the DoS measurements behind OcctKernel.MAX_TRIANGLES, the two classic OCCT triangulation pitfalls (REVERSED-face winding, TopLoc_Location transforms) this wave’s own regression tests exist to catch, and why a camera-aligned light makes flat shading invisible on an axis-aligned box; see docs/adr/ADR-0012-viewer-camera-interaction.adoc for the camera interaction design (zoom bounds, non-finite-input handling, the camera-state-read-only-in-the-draw-phase recomposition design). (multi-shape-composition-and-fill-light wave) The demo window now shows a three-part assembly — a base plate, a pillar, and a single-edge- filleted block — merged into one world mesh via dev.kstep.geometry.MeshComposition.merge before the first composition (ShapeCanvas itself needed no change: it still consumes exactly one TriangleMesh). See docs/adr/ADR-0013-multi-shape-composition-and-fill-light.adoc. Depends on kstep-geometry, kstep-render, and Compose Multiplatform (org.jetbrains.compose 1.11.1) — see Building below for the scoped google() repository this pulls in.

kstep-tests

Cross-module integration tests (Kotest)

Smoke test for the EXPRESS parser; semantic-model, naming-convention, and code-generation tests; WHERE-rule expression-builder, evaluator, and validator tests (unit-level and an end-to-end fixture integration test); kstep-core DSL builder tests; real-schema six-V1-entity codegen tests; (M1 Welle 5) Part 21 writer, reader, and roundtrip tests (Part21WriterTest, Part21ReaderTest, Part21RoundtripTest); (M2 Welle 1) an end-to-end kstep-mcp test suite (KStepMcpServerTest) driving a real MCP Server through the SDK’s own in-memory ChannelTransport; (M2 Welle 2) McpLoggingTest, which captures System.err around one MCP tool call and asserts the configured slf4j-simple backend actually emitted the tool-outcome log line; (M2 Welle 3) CliMainTest, unit-testing kstep-cli’s pure `resolveCommand argument-dispatch function and its USAGE_TEXT (not main() itself, which calls exitProcess on its error path); and (M2 Welle 4) DerivedAttributeEvaluatorTest (DERIVE-expression evaluation, including all three real ap242-v1-entities.exp DERIVE clauses and a synthetic supported-subset positive case) plus new KStepMcpServerTest cases for NAUO’s UNIQUE UR1 enforcement (conflict, non-conflict, self-overwrite, and the product_definition_formation redundancy proof) — all exercised against either the hand-written AP242-subset fixture or the real-schema excerpt; (M2 Welle 6) KStepScriptHostTest (every KStepScriptOutcome case: a valid multi-part-assembly script, the aggregating root(ValidationResult) form, a structural KSTEP-M-001 violation, a getOrThrow() abort, a Kotlin syntax error, an unresolved reference, a wrong-last-expression- type script, a plain runtime exception, and empty/whitespace-only scripts — none of them ever throw out of the host), KStepScriptExportTest (the hello-assembly.kstep.kts/hello-invalid.kstep.kts fixtures driven end-to-end through KStepScriptHost + Part21Writer, with a self-roundtrip via Part21Reader), and new CliMainTest cases for resolveCommand’s `export argument parsing (--out, --output json, every malformed-flag combination, and USAGE_TEXT’s new `kstep export entry); (Geometrie Welle 1) OcctBridgeSmokeTest (the full kstep-geometry acceptance chain: OCCT version reporting, box topology and volume, AP242/AP203 STEP export, closed-shape rejection, cleanup idempotency, an unknown-native-handle rejection that doesn’t crash the JVM, and dimension validation — every OCCT-dependent case skips cleanly when OcctKernel.availability() reports Unavailable, except the suite’s own first case, which instead hard-fails under -Pkstep.occt.require=true) and OcctBoxValidationExportTest (analogous to FreeCadValidationExportTest, but writing a real MANIFOLD_SOLID_BREP-bearing STEP file for manual STEP-viewer import); (Geometrie Welle 5a) OcctFeatureOperationsTest (extrude/fillet acceptance chain: rectangular and L-shaped profile extrusion including negative height, single- and multi-edge fillet with exact measured volumes, chained fillet-of-a-fillet, composition with a makeBox-built shape, STEP export of a filleted solid, source-shape independence after fillet, radius-too-large and unsuitable-edge OcctGeometryException cases, out-of-range-index and closed-shape rejection, the IllegalArgumentException-vs-OcctGeometryException exception-mapping regression case, degenerate-profile rejection, ungated Kotlin-side validation, !available-gated OcctUnavailableException cases, raw native-method null/length hardening, the MAX_FILLET_INPUT_FACES guard, a use-after-free concurrency stress case, and edge-index-ordering determinism across rebuilds) and OcctFeatureValidationExportTest (analogous to OcctBoxValidationExportTest, writing a real extruded-then-filleted STEP file for manual STEP-viewer import); (Geometrie Welle 5b, Teil 1) FeatureRebuildTest (a FeatureSequence replayed through FeatureRebuilder.rebuild(…​), regression-pinned against OcctFeatureOperationsTest’s own already-measured single- and chained-fillet volumes; the core "change a fillet radius and rebuild" proof that an untouched earlier feature’s result stays bit-for-bit identical while the changed one’s result differs; a too-large and a structurally invalid radius each wrapped into a `FeatureRebuildException naming the correct failing position and cause type; four ungated structural-validation cases — empty sequence, over-length sequence, a fillet referencing its own or a later position; and FeatureRebuildResult.close() closing every shape it built, not just the final one); (Geometrie Welle 6, pulled forward) PlaneGcsBridgeSmokeTest (the full kstep-constraints distance-only acceptance chain: a real two-point distance solve, a real 3-4-5 right-triangle solve, a deliberately conflicting system failing cleanly without corrupting a later solve, an already-satisfied constraint staying stable, two concurrency stress cases, both raw native-method JNI-robustness cases (null arrays; malformed lengths, out-of-range indices, unknown constraint kinds, and native hardening for pointCount == 0/all-fixed systems), and full Kotlin-side input validation — every PlaneGCS-dependent case skips cleanly when PlaneGcsSolver.availability() reports Unavailable, except the suite’s own first case, which instead hard-fails under -Pkstep.planegcs.require=true); (C-Welle 2) PlaneGcsConstraintTypesSmokeTest (coincidence, horizontal/vertical, and point-on-line solved individually and combined — including an axis-aligned rectangle built purely from horizontal/vertical/distance constraints, a point pinned onto an infinite line together with a distance constraint, two verified-FAILED contradictory systems, an 8-thread mixed-constraint-type concurrency stress case, a solve(…​) vs. solveDistances(…​) equivalence regression guard, full Kotlin-side validation for the new constraint types, and two dense-graph DoS regression guards at MAX_POINTS/MAX_CONSTRAINTS (coincidence-only and point-on-line-only) — see docs/adr/ADR-0007-planegcs-additional-constraint-types.adoc for the measurements behind those bounds and this wave’s reduced DoS-measurement scope; (C-Welle 2c) the same suite continues with Parallel/Perpendicular solved individually (including the direction-agnostic cross/dot-product idiom and a shared-endpoint rectangle-corner regression guard), a rotated (non-axis-aligned) rectangle built purely from Perpendicular/Distance —  no Horizontal/Vertical — a Parallel+Perpendicular contradiction that resolves by collapsing a leg rather than reporting FAILED, both new kinds layered onto the existing combined system without disturbing it, an extended 8-thread concurrency case, full Kotlin-side validation, and three further dense-graph DoS regression guards (Parallel-only, Perpendicular-only, and both blended into the existing worst-case inconsistent-distance topology) — see docs/adr/ADR-0014-planegcs-parallel-and-perpendicular.adoc for the measurements and the GCS::Line lifetime verification behind them); (Geometrie Welle 4) Part21TolerantReaderTest (the full value grammar under Part21ReadMode.TOLERANT — numeric-lexeme verbatim round-tripping, enumerations, *, typed parameters, complex instances, opaque-vs-typed-constructed known/unknown entities, a dangling reference and a reference cycle each reached through the new value forms, and a regression guard against mistaking a # inside a string literal for a reference), Part21DocumentMergeTest (Part21Document.renumbered shifting references at every nesting level including complex-instance parts, Part21Document.concat’s duplicate-id rejection, and `Part21Renderer’s injection-guard rejections for a malformed `Num lexeme, an embedded backslash, and an invalid entity name), and Ap242ShapeExportRoundtripTest (end-to-end, OCCT-gated: a real 20x30x40mm box merged with a real validated product structure, re-imported, and checked for the actual SHAPE_DEFINITION_REPRESENTATIONPRODUCT_DEFINITION_SHAPEProductDefinition identity link and matching B-Rep element counts, not just that both halves are present)

Building

Requires JDK 21. The Gradle wrapper pins Gradle 9.6.1.

./gradlew clean check

check runs ktlint (now including kstep-mcp, kstep-cli, and kstep-script), kstep-express’s `generateExpressKotlin task (regenerates the six V1 entities from real-schema ground truth — see Status above — and fails the build if its success/skip counts ever drift from the documented boundary), and the Kotest test suite in kstep-tests: the EXPRESS parser smoke test, semantic-model tests, naming-convention tests, code-generation tests (including real-schema six-V1-entity regeneration), WHERE-rule expression-builder/evaluator/ validator tests, kstep-core DSL builder tests, (M1 Welle 5) kstep-step21 Part 21 writer/reader/roundtrip tests, (M2 Welle 1) the end-to-end kstep-mcp MCP server test suite (KStepMcpServerTest), (M2 Welle 3) CliMainTest for kstep-cli’s argument-dispatch logic, and (M2 Welle 6) `KStepScriptHostTest/KStepScriptExportTest for kstep-script’s scripting host, plus `CliMainTest’s new `export argument-parsing cases. kstep-mcp, kstep-cli, and kstep-script themselves compile and are ktlint-checked as part of check; none of them has tests of its own — their tests live in kstep-tests, matching every other module’s pattern (the two *.kstep.kts test fixtures are resources under kstep-tests/src/test/resources, loaded via getResourceAsStream — not under kstep-script itself, for the same reason).

Building — continuous integration

.github/workflows/ci.yml runs two jobs on every push to master, every pull request, and on demand (workflow_dispatch):

Job Runner Native deps installed Gradle invocation What it proves

build

ubuntu-latest (GA, Ubuntu 24.04 as of 09/2026)

Eigen, Boost, Skiko runtime libs — deliberately not OCCT

./gradlew clean check -Pkstep.planegcs.require=true

The stable gate: compilation, ktlint, generateExpressKotlin, the full Kotest suite, real PlaneGCS coverage, and — because OCCT is absent — the graceful-degradation path (OcctAvailability.Unavailable) that no other job exercises.

build-native

ubuntu-26.04 (public preview as of 09/2026)

OCCT (4 packages) + Eigen + Boost + Skiko runtime libs

./gradlew clean check -Pkstep.occt.require=true -Pkstep.planegcs.require=true

Real, end-to-end OCCT coverage: OcctBridgeSmokeTest, OcctFeatureOperationsTest, the AP242 shape-export roundtrip, headless render, and CLI render integration.

The two-job split exists because there is no GitHub-hosted GA runner image with an OCCT new enough for this project: ubuntu-latest (Ubuntu 24.04) ships OCCT 7.6.3, whose STEP toolkits are still named TKSTEP/TKSTEPBase/TKSTEPAttrlibTKDESTEP.so (OCCT 7.8+, what kstep-geometry/build.gradle.kts links against via -lTKDESTEP) does not exist there. Installing the OCCT dev packages on that runner would make compileOcctBridge run (headers present) and then fail at link time — worse than not installing them, where the task is skipped and the documented graceful-degradation path runs instead. ubuntu-26.04 is the only GitHub-hosted image whose OCCT (7.9.2) provides libTKDESTEP.so, matching this project’s own development environment; it is in public preview as of this writing, so build on the GA label stays the gate that never depends on a preview image. If the preview label ever becomes unreliable (capacity, image regressions), the documented fallback is runs-on: ubuntu-latest with container: ubuntu:26.04.

Building — optional OCCT geometry bridge

kstep-geometry (Geometrie Welle 1, extended by Geometrie Welle 5a for extrude/fillet — see docs/adr/ADR-0005-occt-jni-bridge.adoc and docs/adr/ADR-0008-occt-feature-operations.adoc) links against a system-installed OpenCASCADE Technology (OCCT) library. This is entirely optional: ./gradlew clean check stays green with or without it, and no OCCT binary/header/source is vendored in this repository. To get real end-to-end geometry coverage (as opposed to only the graceful-degradation path), install the OCCT dev packages first — on Ubuntu/Debian:

sudo apt-get install --no-install-recommends \
  libocct-foundation-dev libocct-modeling-data-dev \
  libocct-modeling-algorithms-dev libocct-data-exchange-dev
./gradlew clean check -Pkstep.occt.require=true

Without those packages installed, kstep-geometry’s `compileOcctBridge Gradle task is skipped (not failed), and dev.kstep.geometry.OcctKernel.availability() reports OcctAvailability.Unavailable at runtime with a human-readable reason; OcctBridgeSmokeTest/OcctFeatureOperationsTest’s OCCT-dependent cases then skip cleanly instead of failing. `-Pkstep.occt.require=true turns that into a hard test failure instead — pass it only on a machine where OCCT is expected to actually be present. This wave supports linux-x86-64 only; see the ADRs' Folge-Wellen for the planned macOS/Windows follow-up. BRepFilletAPI_MakeFillet (Geometrie Welle 5a) lives in TKFillet, from the same libocct-modeling-algorithms-dev package as TKPrim/TKTopAlgo above — no additional apt-get package is required beyond the four already listed. BRepMesh_IncrementalMesh (Viewer-Welle 1, kstep-viewer’s triangulation) lives in `TKMesh, from the same libocct-modeling-algorithms-dev package too — still no additional apt-get package required.

Building — headless preview rendering (kstep render)

kstep-render (headless-preview-rendering wave — see docs/adr/ADR-0011-headless-preview-rendering.adoc) depends only on kstep-geometry — no Compose, no google() repository entry, no extra setup beyond the optional OCCT bridge above (and kstep render still renders a clean product-structure/fallback preview without it — see Usage below). kstep-cli’s `render subcommand depends on kstep-render directly, deliberately NOT on kstep-viewer — verify that stays true after any dependency change with ./gradlew :kstep-cli:dependencies --configuration runtimeClasspath | grep -iE "skiko|compose|androidx" (must be empty).

Building — Asciidoctor pre-processing (kstep asciidoc)

kstep-preview (the shared pipeline kstep render/kstep asciidoc both call) and kstep-docs:kstep-asciidoc (the scanner/rewriter/processor) need no extra setup beyond kstep-render’s own — no Compose, no OCCT-specific build step. `:kstep-docs itself carries no build.gradle.kts; it is a pure Gradle namespace directory grouping :kstep-docs:kstep-asciidoc, mirroring kuml-dev/kUML’s own `kuml-docs/ layout. See docs/adr/ADR-0019-kstep-asciidoc.adoc.

Building — glTF/GLB validation (optional)

kstep render -f glb’s output (`dev.kstep.render.gltf.GlbWriter, see docs/adr/ADR-0016-gltf-glb-export.adoc) needs no extra setup to BUILD or TEST at the byte level — GlbWriterTest (kstep-render) covers the writer’s JSON/binary layout with no OCCT and no Node.js at all, and is part of the normal ./gradlew clean check. Cross-checking a real .glb against the official Khronos gltf-validator is a genuinely optional, manual step, kept OUT of the Gradle build entirely (this repository has no established pattern of build-time network access — see that ADR’s Security section):

cd scripts && npm ci
node validate-gltf.mjs ../path/to/your.glb
# or, cross-checking against a known triangle/vertex count:
node validate-gltf.mjs ../path/to/your.glb --expect-triangles 12 --expect-vertices 36

Exits 0 only if the validator reports zero errors AND zero warnings (and, when given, the --expect-* counts match exactly). GltfValidationExportTest (kstep-tests, OCCT-gated) writes real .glb fixtures unconditionally under kstep-tests/build/gltf-validation/, and additionally RUNS this same validator itself, as a real Node subprocess, when both OCCT is available AND -Pkstep.gltf.validate=true is passed — that flag HARD-FAILS (never silently skips) if node/scripts/node_modules are missing, mirroring -Pkstep.occt.require/`-Pkstep.planegcs.require’s own "a gate that can silently no-op is not a gate" rule:

cd scripts && npm ci && cd ..
./gradlew clean check -Pkstep.occt.require=true -Pkstep.gltf.validate=true

Building — optional desktop viewer

kstep-viewer (Viewer-Welle 1 — see docs/adr/ADR-0010-occt-triangulation-and-viewer.adoc) depends on both the optional OCCT bridge above and Compose Multiplatform. Resolving Compose’s dependencies requires a google() Maven repository entry (scoped to androidx.* coordinates only — see settings.gradle.kts and that ADR’s Context section for why mavenCentral() alone fails), already wired into this repository; no extra setup is needed beyond a normal ./gradlew clean check. Without the OCCT dev packages installed, opening the real viewer window shows an explanatory UnavailableNotice (with the exact apt-get install command above) instead of a blank canvas or a crash.

./gradlew :kstep-viewer:run opens a real, on-screen window showing a static isometric box — never wired into check, and requires an actual display (or Xvfb) to run; every test in this module stays headless (java.awt.headless=true).

Building — optional PlaneGCS constraint solver

kstep-constraints (Geometrie Welle 6, pulled forward, plus C-Welle 2 — see docs/adr/ADR-0006-planegcs-constraint-bridge.adoc and docs/adr/ADR-0007-planegcs-additional-constraint-types.adoc) compiles a vendored copy of the PlaneGCS 2D geometric constraint solver (unlike kstep-geometry’s OCCT, which links a system-installed package — PlaneGCS ships no distribution package at all, so its sources are vendored directly into this repository at a pinned commit; see NOTICE and `kstep-constraints/src/main/cpp/third_party/planegcs/PROVENANCE.adoc). This is entirely optional: ./gradlew clean check stays green with or without the required dev headers installed. To get real end-to-end constraint-solving coverage, install the Eigen and Boost dev packages first — on Ubuntu/Debian:

sudo apt-get install --no-install-recommends libeigen3-dev libboost-dev
./gradlew clean check -Pkstep.planegcs.require=true

Without those packages installed, kstep-constraints’s `compilePlaneGcsBridge Gradle task is skipped (not failed), and dev.kstep.constraints.PlaneGcsSolver.availability() reports PlaneGcsAvailability.Unavailable at runtime with a human-readable reason; PlaneGcsBridgeSmokeTest’s PlaneGCS-dependent cases then skip cleanly instead of failing. `-Pkstep.planegcs.require=true turns that into a hard test failure instead — pass it only on a machine where the bridge is expected to actually be present. This wave supports linux-x86-64 only, same as kstep-geometry; see the ADR’s Folge-Wellen for the planned macOS/Windows follow-up. Unlike the OCCT bridge, no -L/-l/-rpath linker flags are needed at all — Eigen and Boost are both used here in a strictly header-only capacity, so nothing links against an external shared library.

kSTEP logs through kotlin-logging (io.github.oshai:kotlin-logging-jvm), an idiomatic Kotlin wrapper over SLF4J. kstep-mcp’s server lifecycle and tool-call outcomes are the first real usage of it (M2 Welle 2); `kstep-core, kstep-express, and kstep-step21 remain untouched, since their existing structured- exception/ValidationResult error model already covers their diagnostic needs. (M2 Welle 6) kstep-script’s `KStepScriptHost is the second real usage: it logs a warning if host.eval(…​) itself throws before producing a scripting result at all (the one case its own KStepScriptOutcome mapping can’t attribute to the script source) — every expected outcome (compile error, validation failure, runtime exception) is structured data, not a log line. The SLF4J backend is slf4j-simple (MIT-licensed, zero-config, prints to stderr) — kstep-mcp depends on it at runtimeOnly, and kstep-tests carries a matching testImplementation, so ./gradlew clean check now shows real log output instead of SLF4J’s "No SLF4J providers were found …​ Defaulting to no-operation (NOP) logger" warning. Ap242V1CodeGen’s `generateExpressKotlin console report and kstep-cli’s `main() — including its USAGE_TEXT help output (M2 Welle 3) and (M2 Welle 6) kstep export’s own success/error rendering, text or JSON — remain plain `println deliberately: that is Gradle-task/CLI stdout output, not diagnostic logging, and stays outside kotlin-logging’s scope.

generateExpressKotlin can also be run on its own:

./gradlew :kstep-express:generateExpressKotlin

It writes generated Kotlin source under kstep-express/build/generated/expressKotlin/main. That output is a build artifact only, not added to any module’s sourceSet — two of the five entities it successfully generates (Product, ProductDefinition) reference support entity types (ProductContext, ProductDefinitionContext) that themselves cannot be code-generated in V1 (see Status above), so wiring the output into an actual compilation would break it.

Usage (current capability)

Parsing an EXPRESS schema and generating Kotlin `data class`es from it works end-to-end today:

import dev.kstep.express.codegen.ExpressKotlinCodeGenerator
import dev.kstep.express.semantic.ExpressSemanticModelBuilder

val schema = """
    SCHEMA example_schema;
      ENTITY product;
        id   : STRING;
        name : STRING;
      END_ENTITY;
    END_SCHEMA;
""".trimIndent()

val model = ExpressSemanticModelBuilder.build(schema)
val kotlinSource = ExpressKotlinCodeGenerator.generateFileSource(
    model.schemas.single(),
    "dev.kstep.generated.example",
)
println(kotlinSource)
// package dev.kstep.generated.example
//
// public data class Product(
//   public val id: String,
//   public val name: String,
// )

ExpressParserFactory.parse (used internally by ExpressSemanticModelBuilder.build) throws ExpressSyntaxException on the first syntax error instead of returning a partial tree. ExpressSemanticModelBuilder throws SemanticModelException for a named type that resolves to neither a known entity nor a known TYPE in the same schema. ExpressKotlinCodeGenerator throws CodeGenException for EXPRESS constructs it doesn’t yet turn into Kotlin (see Status above). None of this is wired into the CLI yet; the generateExpressKotlin Gradle task (see Building above) is the one place it is wired into a Gradle task today, scoped to the six V1 entities.

Building a kstep-core DSL entity runs WHERE-rule validation and returns a structured result instead of throwing for a validation failure:

import dev.kstep.core.ValidationResult
import dev.kstep.core.ap242.applicationContext
import dev.kstep.core.ap242.product
import dev.kstep.core.ap242.productContext
import dev.kstep.core.getOrThrow

// product.frame_of_reference is a mandatory SET [1:?] OF product_context in the real
// AP242 schema (M2 Welle 10 — see docs/adr/ADR-0004-core-on-generated-types.adoc);
// kstep-core does not invent a placeholder context behind the caller's back.
val appCtx = applicationContext { application = "config control" }.getOrThrow()
val prodCtx = productContext {
    name = "engineering"
    frameOfReference = appCtx
    disciplineType = "mechanical"
}.getOrThrow()

val result = product(id = "BRK-001") {
    name = "Bracket"
    description = "Mounting bracket"
    frameOfReference = setOf(prodCtx)
}
when (result) {
    is ValidationResult.Valid -> println(result.value)
    is ValidationResult.Invalid -> println(result.violations)
}

// An empty id violates product's WHERE rule (kstep_wr1: SELF.id <> ''):
val invalid = product(id = "") { name = "Bracket"; frameOfReference = setOf(prodCtx) }
println((invalid as ValidationResult.Invalid).violations)
// [DslViolation(code=KSTEP-W-001, entityName=product, ruleLabel=kstep_wr1,
//   expressionText=SELF.id <> '', message=WHERE rule kstep_wr1 not satisfied: SELF.id <> '')]

WhereRuleValidator.validate — the lower-level API kstep-core’s builders call internally — re-parses WHERE-rule expression text and evaluates it against a `Map<String, WhereRuleValue>:

import dev.kstep.express.validation.WhereRuleSpec
import dev.kstep.express.validation.WhereRuleValidator
import dev.kstep.express.validation.WhereRuleValue

val violations = WhereRuleValidator.validate(
    entityName = "approval",
    rules = listOf(WhereRuleSpec(label = "wr1", expressionText = "SELF.level <> ''")),
    attributeValues = mapOf("level" to WhereRuleValue.StringValue("")),
)
println(violations)
// [WhereRuleViolation(entityName=approval, ruleLabel=wr1, expressionText=SELF.level <> '', sourceLine=0)]

A WHERE-rule expression using a construct outside the supported subset (EXISTS(), QUERY, arithmetic, …​) throws UnsupportedWhereExpressionException; a genuine evaluation-time problem (a missing attribute, a non-boolean result, an incompatible-type comparison) throws WhereRuleEvaluationException. Neither is ever silently swallowed into an empty violation list.

(M1 Welle 5) Exporting a kstep-core product structure to a STEP Part 21 physical file, then reading it back:

import dev.kstep.core.ap242.applicationContext
import dev.kstep.core.ap242.product
import dev.kstep.core.ap242.productContext
import dev.kstep.core.ap242.productDefinition
import dev.kstep.core.ap242.productDefinitionContext
import dev.kstep.core.ap242.productDefinitionFormation
import dev.kstep.core.getOrThrow
import dev.kstep.step21.Part21Header
import dev.kstep.step21.Part21Reader
import dev.kstep.step21.Part21Writer

val appCtx = applicationContext { application = "config control" }.getOrThrow()
val prodCtx = productContext {
    name = "engineering"; frameOfReference = appCtx; disciplineType = "mechanical"
}.getOrThrow()
val defCtx = productDefinitionContext {
    name = "engineering"; frameOfReference = appCtx; lifeCycleStage = "design"
}.getOrThrow()

val bracket = product("BRK-001") { name = "Bracket"; frameOfReference = setOf(prodCtx) }.getOrThrow()
val builtFormation = productDefinitionFormation("BRK-001-F") { ofProduct = bracket }.getOrThrow()
val definition = productDefinition("BRK-001-D") {
    formation = builtFormation
    frameOfReference = defCtx
}.getOrThrow()

val header = Part21Header(
    fileName = "bracket.step",
    timestamp = "2026-07-19T12:00:00",
    schemaIdentifiers = listOf("AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF"),
)
val exported = Part21Writer.write(header, listOf(definition))
println(exported)
// ISO-10303-21;
// HEADER;
// FILE_DESCRIPTION((),'2;1');
// FILE_NAME('bracket.step','2026-07-19T12:00:00',(),(),'','kSTEP','');
// FILE_SCHEMA(('AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF'));
// ENDSEC;
// DATA;
// #1=APPLICATION_CONTEXT('config control');
// #2=PRODUCT_CONTEXT('engineering',#1,'mechanical');
// #3=PRODUCT('BRK-001','Bracket',$,(#2));
// #4=PRODUCT_DEFINITION_FORMATION('BRK-001-F',$,#3);
// #5=PRODUCT_DEFINITION_CONTEXT('engineering',#1,'design');
// #6=PRODUCT_DEFINITION('BRK-001-D',$,#4,#5);
// ENDSEC;
// END-ISO-10303-21;

val result = Part21Reader.read(exported)
println(result.isFullySuccessful) // true

Part21Reader.read throws Part21SyntaxException, Part21EncodingException, Part21DanglingReferenceException, Part21CycleException, or Part21LimitExceededException for structurally malformed input (see Status above). A WHERE-rule failure while reconstructing a parsed instance is never thrown — it surfaces in Part21ReadResult.violations, with any dependent instance recorded in Part21ReadResult.skipped instead of attempted:

// A hand-edited file with an empty product id (violates product's kstep_wr1 WHERE rule):
val handEdited = """
    ISO-10303-21;
    HEADER;
    FILE_DESCRIPTION((),'2;1');
    FILE_NAME('n','t',(),(),'','','');
    FILE_SCHEMA(('S'));
    ENDSEC;
    DATA;
    #1=APPLICATION_CONTEXT('cc');
    #2=PRODUCT_CONTEXT('eng',#1,'mech');
    #3=PRODUCT('','x','',(#2));
    #4=PRODUCT_DEFINITION_FORMATION('PDF-001','',#3);
    ENDSEC;
    END-ISO-10303-21;
""".trimIndent()

val badResult = Part21Reader.read(handEdited)
println(badResult.violations) // {3=[DslViolation(code=KSTEP-W-001, ...)]}
println(badResult.skipped)    // {4=[3]}

Part21Writer/Part21Reader are called directly here as a library API; (M2 Welle 6) below, the kstep export CLI subcommand wraps this same pair via the kstep-script scripting DSL instead of hand-written Kotlin.

(M2 Welle 1) Driving the kstep-mcp server end-to-end from an MCP client — the same tool-call sequence an LLM agent would make, here shown via the SDK’s own in-memory ChannelTransport rather than a real stdio subprocess:

import dev.kstep.mcp.buildServer
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.testing.ChannelTransport
import io.modelcontextprotocol.kotlin.sdk.types.Implementation

val server = buildServer() // registers all fifteen tools on a fresh EntityStore
val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair()
server.createSession(serverTransport)

val client = Client(clientInfo = Implementation(name = "example-client", version = "1.0"))
client.connect(clientTransport)

// product.frame_of_reference is mandatory (M2 Welle 10) — build a context first.
client.callTool("build_application_context", mapOf("handle" to "AC", "application" to "cc"))
client.callTool(
    "build_product_context",
    mapOf("handle" to "PC", "name" to "eng", "frame_of_reference_handle" to "AC", "discipline_type" to "mech"),
)

val built = client.callTool(
    "build_product",
    mapOf("id" to "BRK-001", "name" to "Bracket", "frame_of_reference_handles" to listOf("PC")),
)
println(built.structuredContent)
// {"id":"BRK-001","name":"Bracket","description":null,"frame_of_reference_handles":["PC"],"entityType":"product"}

// A validation failure comes back as structured content, not a protocol-level error.
// Both the empty id (WHERE rule) and the never-set name (mandatory attribute, (M2 Welle 7))
// are collected in the same response, not just the first one found:
val invalid = client.callTool(
    "build_product",
    mapOf("id" to "", "frame_of_reference_handles" to listOf("PC")),
)
println(invalid.isError) // true
println(invalid.structuredContent)
// {"errorKind":"validation_failed","violations":[
//   {"code":"KSTEP-W-001","entityName":"product", ...},
//   {"code":"KSTEP-M-002","entityName":"product","message":"required attribute 'name' ..."}
// ]}

To run the server for real, kstep-cli wraps this directly (M2 Welle 3):

kstep mcp

This blocks on stdin/stdout until the session closes, exactly like runStdioServer() above — kstep mcp is a one-line call to it, nothing more. In practice, expect the process to be terminated by its MCP host (SIGTERM/SIGKILL) rather than to exit on its own from a clean stdin close: an immediate stdin EOF does not reliably make runStdioServer() return in the underlying kotlin-sdk-server:0.14.0 transport, a pre-existing SDK behavior out of scope for kstep-cli to work around (see Status above). Once runStdioServer() does return — whichever way the session actually closes — the process exits on its own with code 0; no explicit shutdown call is needed on that path.

(M2 Welle 4) Evaluating a DERIVE initializer expression works exactly like WhereRuleValidator.validate above, via DerivedAttributeEvaluator:

import dev.kstep.express.validation.DerivedAttributeEvaluator
import dev.kstep.express.validation.WhereRuleValue

val value = DerivedAttributeEvaluator.evaluate(
    expressionText = "SELF.id",
    attributeValues = mapOf("id" to WhereRuleValue.StringValue("W-1")),
)
println(value) // StringValue(value=W-1)

// The real AP242 product_definition.name DERIVE (get_name_value(SELF)) is a function
// call, outside the supported subset — this throws, exactly like an equivalent WHERE
// rule would:
// DerivedAttributeEvaluator.evaluate("get_name_value(SELF)", emptyMap())
// -> UnsupportedWhereExpressionException

build_next_assembly_usage_occurrence now also enforces NAUO’s real UNIQUE UR1 rule — a second NAUO sharing an already-used (reference_designator, relating_product_definition) pair is rejected:

// after building NAUO-1 with reference_designator "RD-1" and
// relating_product_definition_id "PD-A":
val conflict = client.callTool(
    "build_next_assembly_usage_occurrence",
    mapOf(
        "id" to "NAUO-2",
        "relating_product_definition_id" to "PD-A",
        "related_product_definition_id" to "PD-OTHER",
        "reference_designator" to "RD-1",
    ),
)
println(conflict.isError) // true
println(conflict.structuredContent)
// {"errorKind":"unique_constraint_violated","entityType":"next_assembly_usage_occurrence",
//  "ruleLabel":"UR1","conflictingId":"NAUO-1","fields":[...]}

By contrast, build_product_definition_formation deliberately has no such UNIQUE check — and needs none. Its real UR1 is (id, of_product), and because the EntityStore already keys every formation by id, two formations can only share a composite key by sharing id, which is a re-build (overwrite), not a second instance. Two formations of the same product under different ids both succeed (a product legitimately has many formations); re-building under the same id overwrites. No unique_constraint_violated is reachable for this entity — verified by test, not just asserted:

// after building product "SHARED-PRODUCT":
val first = client.callTool(
    "build_product_definition_formation",
    mapOf("id" to "PDF-A", "of_product_id" to "SHARED-PRODUCT"),
)
val second = client.callTool(
    "build_product_definition_formation",
    mapOf("id" to "PDF-B", "of_product_id" to "SHARED-PRODUCT"),
)
println(first.isError)  // null — different id, so (id, of_product) cannot collide
println(second.isError) // null — same story, even though of_product is shared

// rebuilding PDF-A under the SAME id overwrites, it does not conflict:
val rebuilt = client.callTool(
    "build_product_definition_formation",
    mapOf("id" to "PDF-A", "of_product_id" to "SHARED-PRODUCT"),
)
println(rebuilt.isError) // null

(M2 Welle 6) Exporting a .kstep.kts script directly to a STEP Part 21 file via kstep export, no hand-written Kotlin caller needed. A script ends with stepFile(fileName = "…​") { …​ }; the twelve kstep-core builders (M2 Welle 10), the generated dev.kstep.generated.ap242v1. types, and stepFile/root are all available without any import (KStepScriptCompilationConfiguration’s `defaultImports):

// bracket.kstep.kts
val appCtx = applicationContext { application = "config control" }.getOrThrow()
val prodCtx = productContext {
    name = "engineering"; frameOfReference = appCtx; disciplineType = "mechanical"
}.getOrThrow()
val defCtx = productDefinitionContext {
    name = "engineering"; frameOfReference = appCtx; lifeCycleStage = "design"
}.getOrThrow()

val bracket = product("BRK-001") { name = "Bracket"; frameOfReference = setOf(prodCtx) }.getOrThrow()
val bracketFormation = productDefinitionFormation("BRK-001-F") { ofProduct = bracket }.getOrThrow()
val definition = productDefinition("BRK-001-D") {
    formation = bracketFormation
    frameOfReference = defCtx
}.getOrThrow()

stepFile(fileName = "bracket.step") {
    root(definition)
}
kstep export bracket.kstep.kts
# Exported 1 root(s) to bracket.step

--out overrides the derived output path; --output json renders the same information as a JSON document instead of human-readable text — useful for tool/LLM consumption (kSTEP-ADR-0001 acceptance criterion #3). The two root(…​) forms differ in what happens to a validation failure: root(entity) takes an already-getOrThrow()-unwrapped entity, so an Invalid result aborts the script immediately; passing the raw ValidationResult to root(…​) instead aggregates every violation across every registered root, which is what the second fixture below demonstrates:

// hello-invalid.kstep.kts — an empty product id violates product's kstep_wr1 WHERE rule
// (frame_of_reference is set deliberately, so this is the only violation in the response)
val prodCtx = productContext {
    name = "engineering"
    frameOfReference = applicationContext { application = "config control" }.getOrThrow()
    disciplineType = "mechanical"
}.getOrThrow()
stepFile(fileName = "hello-invalid.step") {
    root(product(id = "") { name = "Nameless"; frameOfReference = setOf(prodCtx) })
}
kstep export --output json hello-invalid.kstep.kts
{"status":"error","errorKind":"validation_failed","violations":[
  {"code":"KSTEP-W-001","entityName":"product","ruleLabel":"wr1",
   "expressionText":"SELF.id <> ''","message":"WHERE rule wr1 not satisfied: SELF.id <> ''"}
]}

A Kotlin syntax error, an unresolved reference, a script whose last expression isn’t a KStepModel, and a plain runtime exception all produce their own structured KSTEP-S-xxx error document the same way (compilation_error, no_model_produced, runtime_error) — see Status above for the full KStepScriptOutcome breakdown — never a raw Kotlin stack trace on stdout/stderr. Both fixture scripts above are the actual kstep-tests test resources (hello-assembly.kstep.kts is the 3-part-assembly variant of the first example), not just README prose — see the kstep-tests Modules row above.

Rendering a headless SVG/PNG/text/glTF preview of a *.kstep.kts script via kstep render (see docs/adr/ADR-0011-headless-preview-rendering.adoc and, for -f glb, docs/adr/ADR-0016-gltf-glb-export.adoc) — no window, no display server. shape(…​) (also available with no import, alongside root(…​)) registers geometry for the preview independently of root(…​):

// hello-box.kstep.kts
val appCtx = applicationContext { application = "config control" }.getOrThrow()
val prodCtx = productContext {
    name = "engineering"; frameOfReference = appCtx; disciplineType = "mechanical"
}.getOrThrow()
val defCtx = productDefinitionContext {
    name = "engineering"; frameOfReference = appCtx; lifeCycleStage = "design"
}.getOrThrow()

val part = product("BOX-001") { name = "Box"; frameOfReference = setOf(prodCtx) }.getOrThrow()
val prodFormation = productDefinitionFormation("BOX-001-F") { ofProduct = part }.getOrThrow()
val definition = productDefinition("BOX-001-D") {
    formation = prodFormation
    frameOfReference = defCtx
}.getOrThrow()

// OcctKernel/shape(...) need no import -- see defaultImports above.
val box = OcctKernel.makeBox(10.0, 20.0, 30.0)

stepFile(fileName = "hello-box.step") {
    root(definition)
    shape(definition, box)
}
kstep render hello-box.kstep.kts
# Wrote hello-box.svg  -- an isometric render, since OCCT is available and the box triangulated

kstep render hello-box.kstep.kts --format png -w 1200 --height 900 -o box.png
kstep render hello-box.kstep.kts --format text --with-step

kstep render hello-box.kstep.kts -f glb -o box.glb
# Wrote box.glb -- a self-contained binary glTF 2.0 document (JSON + binary buffer in one
# file); -w/--height are ignored for this format. "-f gltf" is accepted as an alias for
# "glb" -- both write the same binary container, see ADR-0016 for why a separate plain-text
# glTF format is not offered. Open it in any glTF viewer (e.g. https://gltf-viewer.donmccurdy.com/,
# or a local `<model-viewer>` page) to inspect the model interactively.

On a machine without the native OCCT bridge (or if triangulation fails for any other reason), the SAME hello-box.kstep.kts script above still renders successfully — content falls back to a text card explaining why, painted into whatever container was requested (never a silently different file type), with the fixed OCCT-dev-packages apt-get command from Building below included in the card. Pass --require-geometry to turn that silent fallback into a hard failure (exit 1) instead — e.g. for a CI step that must guarantee a real render happened. A script without any shape(…​) call at all (like bracket.kstep.kts above) renders a product-structure summary card the same way, content = summary, exit 0 — kstep render never requires geometry to succeed.

(kstep-asciidoc wave, see docs/adr/ADR-0019-kstep-asciidoc.adoc) Pre-rendering kstep preview blocks in an AsciiDoc document — the DSL-file-per-block-macro equivalent of kuml-dev/kUML’s `kuml-asciidoc pre-processor. Three interchangeable block forms; a document can mix all three:

// bracket-doc.adoc

A markdown fence, no attributes:

```kstep
val appCtx = applicationContext { application = "config control" }.getOrThrow()
val prodCtx = productContext { name = "engineering"; frameOfReference = appCtx; disciplineType = "mechanical" }.getOrThrow()
val defCtx = productDefinitionContext { name = "engineering"; frameOfReference = appCtx; lifeCycleStage = "design" }.getOrThrow()
val bracket = product("BRK-001") { name = "Bracket"; frameOfReference = setOf(prodCtx) }.getOrThrow()
val bracketFormation = productDefinitionFormation("BRK-001-F") { ofProduct = bracket }.getOrThrow()
val definition = productDefinition("BRK-001-D") { formation = bracketFormation; frameOfReference = defCtx }.getOrThrow()
stepFile(fileName = "bracket.step") { root(definition) }
```

An AsciiDoc-native delimited block, with attributes:

[kstep,bracket,format=png,width=800,height=600,alt="Bracket assembly"]
----
... same script as above ...
----

A block macro pointing at an existing script file:

kstep::bracket.kstep.kts[format=svg]
kstep asciidoc --input bracket-doc.adoc --output bracket-doc.rendered.adoc
# Wrote bracket-doc.rendered.adoc (3 block(s))
# -> bracket-doc.rendered.adoc now has three `image::...[]` lines, and the
#    corresponding bracket-doc-1.svg/bracket.png/bracket-doc-3.svg files
#    (named from the INPUT document's basename, and from the second
#    block's explicit `target=bracket`) sit next to it (or under
#    `:imagesdir:`, if the document sets one).

kstep asciidoc --input-dir docs/src --output-dir docs/build --format png
# Mirrors the whole tree: every .adoc is rewritten, every other file
# (images, includes, ...) is copied unchanged. Symlinks are skipped and
# reported, never followed.

Only svg/png are accepted as a block’s image format (auto/text/glb are rejected outright — an embeddable image is the only sensible container here); --format sets the document-wide default, overridable per block via format=. The same Container-Regel/Pflicht-Fallback kstep render follows applies here too (via the shared kstep-preview module) — a script whose geometry cannot be rendered this run becomes a notice card in the requested image format, exit 0, unless --require-geometry is given. A script that fails to compile/validate/run is a different failure class entirely: by default it aborts the WHOLE document (--on-error fail, matching a compiler’s own instinct to stop on a syntax error) — pass --on-error card to instead embed an error card for that one block and keep going, useful while iterating on a document’s scripts. Under the default fail policy, a document with one good block and one broken block writes NEITHER the rewritten .adoc NOR any of its images — never a half-written result.

Any OTHER AsciiDoc delimited block (----/…​./====/**/__/ /////) not opened by a recognized [kstep]/[source,kstep] attribute line passes through untouched — including one that happens to contain kstep::path[]-looking text, e.g. this project’s own docs showing the syntax (see docs/adr/ADR-0019-kstep-asciidoc.adoc’s Stolperfalle 3). A two-line ("setext") section title underline (`Title\n-----------\n) is never mistaken for such a delimiter opener, even though a ----------- run alone is indistinguishable from one — the scanner checks the line above it first, matching Asciidoctor’s own rule (Stolperfalle 12 in the ADR).

Roadmap (V1 scope)

Per the project’s scope-reduction decision (kSTEP-ADR-0001, analogous to kUML-ADR-0004), V1 deliberately excludes B-rep geometry and PMI, and focuses on a semantic core that a type-safe DSL benefits from most:

  1. EXPRESS parser (done) + EXPRESS-to-Kotlin code generation (done, see above — semantic model and KotlinPoet-based generator, verified end-to-end against the six-entity AP242-subset fixture, and, as of M1 Welle 4, against the real, official AP242 schema and its six V1 entities — see Status and Building above). Supertype/subtype attribute inheritance (needed for next_assembly_usage_occurrence and most real AP242 entities, and for the two support entities product_context/ product_definition_context that product/product_definition reference) is now done too — see the SUBTYPE OF inheritance-flattening entry in Status above (dev.kstep.express.semantic.InheritanceResolver). Still pending: DERIVE/INVERSE/UNIQUE clause codegen (still not started), and SELECT/ENUMERATION-type and transitive-TYPE-alias resolution. DERIVE/UNIQUE evaluation (as opposed to codegen) is now partially done — capture was M1 Welle 6, and M2 Welle 4 added evaluation for the WHERE-rule- supported expression subset (DerivedAttributeEvaluator) plus next_assembly_usage_occurrence’s `UNIQUE UR1 enforcement in kstep-mcp — see Status above. Still open: INVERSE evaluation (no real V1 entity has an INVERSE clause, and kstep-core has no bidirectional-relationship modeling to evaluate against) and NAUO’s UNIQUE UR2 (depends on product_definition_occurrence, an entity not modeled in kstep-core) — both carried forward explicitly as named limitations, not silently dropped.

  2. A semantic AP242 core: product structure and metadata, without B-rep geometry and without PMI (PMI references geometry shape aspects and moves with the geometry milestone). The Kotlin type layer for its twelve core AP242 entities (six V1 entities — PRODUCT, PRODUCT_DEFINITION + revision, PRODUCT_DEFINITION_FORMATION, NEXT_ASSEMBLY_USAGE_OCCURRENCE, APPROVAL, PERSON_AND_ORGANIZATION — plus six support entities their generated shapes require: APPLICATION_CONTEXT, PRODUCT_CONTEXT, PRODUCT_DEFINITION_CONTEXT, APPROVAL_STATUS, PERSON, ORGANIZATION) now generates correctly from the fixture and from the real AP242 schema (see Status above), and (M2 Welle 10) kstep-core now builds its runtime construction/ validation API — one validating builder function per entity in dev.kstep.core.ap242 — directly on top of those generated types, rather than hand-authoring independent equivalents of them. This resolves the fork M2 Welle 9 left open (extend the hand-authored layer with LIST/EXISTS() support, vs. rebuild it on the generated types): eleven of the thirteen M2 Welle 8 divergence-table entries (below, kept for historical reference) are resolved outright — the codegen-generated shape is the real shape, by construction — and the remaining two (approval.status/person_and_organization. the_person+the_organization simplified to String, and the invented approval.authorized_by) are resolved the same way: those attributes are now correctly entity-typed, and authorized_by — never part of the real entity — is removed, not replaced. Full rationale, consequences (a real ergonomic regression on product/ productDefinition, which now need an explicit context built first; Part-21/MCP breaking changes; the new KSTEP-A-001 violation code; EXISTS() WHERE-rule support), and the alternatives considered are in docs/adr/ADR-0004-core-on-generated-types.adoc.

    The M2 Welle 8 divergence inventory, for historical reference — every row below is now resolved (see ADR-0004), not an open gap:

    Entity Divergence (as of M2 Welle 8) M2 Welle 10 resolution

    approval

    status is entity-typed (approval_status) in the real schema; String in kstep-core

    resolved — status is now dev.kstep.generated.ap242v1.ApprovalStatus

    approval

    authorized_by does not exist on the real entity at all — a kSTEP-invented convenience linkage

    resolved — removed entirely, no replacement

    approval

    real entity has no WHERE rule; kstep-core’s `wr1 is synthesized

    unchanged — kept, relabeled kstep_wr1 (never a real rule to align to)

    person_and_organization

    the_person/the_organization are entity-typed (person/organization) in the real schema; String in kstep-core

    resolved — both now correctly entity-typed and mandatory

    person_and_organization

    real WR1/WR2 use SIZEOF(USEDIN(…​)) (unsupported); `kstep-core’s rule was synthesized

    resolved differently — dropped entirely, not relabeled: both attributes are now mandatory entity references, so "at least one set" is no longer a meaningful approximation

    product

    real entity has a mandatory frame_of_reference : SET [1:?] OF product_context; kstep-core omitted it

    resolved — mandatory and non-empty, enforced via KSTEP-M-001/KSTEP-A-001

    product

    real WR1 uses SIZEOF(USEDIN(…​)) (unsupported); `kstep-core’s rule is synthesized

    unchanged — kept, relabeled kstep_wr1

    product_definition

    real entity has a mandatory frame_of_reference : product_definition_context; kstep-core omitted it

    resolved — mandatory, enforced via KSTEP-M-001

    product_definition

    real WR1 uses SIZEOF(USEDIN(…​)) (unsupported); `kstep-core’s rule is synthesized

    unchanged — kept, relabeled kstep_wr1

    product, product_definition, product_definition_formation

    real OPTIONAL text description; kstep-core modeled it as non-null String defaulting to ""

    resolved — genuinely nullable String? now, matching the generated type

    product_definition_formation

    real UNIQUE UR1: id, of_product not enforced anywhere in kstep-core

    unchanged — still resolved by construction (EntityStore id-keying), see KStepMcpServerTest

    next_assembly_usage_occurrence

    real flattened shape inherits an OPTIONAL text description (product_definition_relationship); kstep-core omitted it

    resolved — description is now a genuine String?

    next_assembly_usage_occurrence

    real inherited reference_designator (assembly_component_usage) is OPTIONAL identifier; kstep-core narrowed it to non-null String defaulting to ""

    resolved — genuinely String? now; the over-constraining synthesized WHERE rule that used to force it non-blank is removed, not relabeled

    next_assembly_usage_occurrence

    real WR1 is acyclic_product_definition_relationship(…​) (unsupported); `kstep-core’s rule is synthesized

    unchanged — no synthesized rule remains for this entity (the old one was the now-removed reference_designator over-constraint above)

    Deliberately still open (named limitations, not silently dropped — see ADR-0004 and Status for KSTEP-M-002’s scope): the `SIZEOF/USEDIN-based real WHERE rules on product, product_definition, person_and_organization, and application_context remain unsupported and unevaluated; NAUO’s real WR1 (acyclic_product_definition_relationship) likewise; DERIVE attributes (product_definition.name, person_and_organization.name/ description, application_context.id/description, NAUO’s derived product_definition_occurrence_id) are still not codegen’d or evaluated — the generated types simply don’t carry them; and NAUO’s UNIQUE UR2 remains unenforced (depends on product_definition_occurrence, an entity not modeled anywhere in kstep-core).

  3. Validation: EXPRESS WHERE rules surfaced as structured errors. Done for the supported expression subset (comparisons, SELF.attribute references, AND/OR/NOT, string/integer/real literals) — see Status above. Constructs outside that subset (EXISTS(), SIZEOF(), other function calls, aggregate/set operations, QUERY, arithmetic operators, the tri-state LOGICAL type) are not evaluated and raise a structured exception instead. Note that WHERE-rule evaluation is not the same thing as EXPRESS mandatory-attribute- presence enforcement (the $-token / non-OPTIONAL mechanism) — (M2 Welle 7) this is now enforced for non-OPTIONAL primitive attributes too, not only entity-typed references: kstep-core’s builders model such an attribute as a nullable presence sentinel (mirroring the existing entity-reference pattern) and emit a structured `KSTEP-M-002 (missing mandatory attribute) when one is left unset, alongside KSTEP-M-001 for references. In the six V1 entities this closes the two real instances — product.name and next_assembly_usage_occurrence.name — both non-OPTIONAL label attributes that previously carried no WHERE rule and so silently accepted an unset value as an empty string. Enforcement is presence, not non-emptiness: an explicitly assigned empty string is a legal value for a non-OPTIONAL STRING that no WHERE rule constrains, and stays Valid. Still deliberately out of scope: non-OPTIONAL attributes on the three hand-authored entities whose Kotlin shape currently diverges from the real schema (approval.status, person_and_organization.the_person/ the_organization are hand-modeled as strings where the real schema has entity-typed references; reconciling those with generated output is the entity-typed-reconciliation item explicitly deferred in the "codegen reconciliation" roadmap entry above), and the general model-driven form (auto-deriving presence checks from ResolvedEntity/ExpressAttribute.isOptional instead of a per- builder null-check) which awaits that reconciliation. No non-STRING (INTEGER/REAL) primitive presence case exists among the six V1 entities, so that reasoning is not yet needed either.

  4. STEP Part 21 export/import. (M1 Welle 5) Done for the kSTEP-own-format half: Part21Writer/Part21Reader in kstep-step21 losslessly roundtrip the six V1 entities through ISO 10303-21 physical file text (Part21Reader.read(Part21Writer.write(header, model)) == model, verified in Part21RoundtripTest) — see Status and Usage above. Still open, and not to be conflated with the above: kSTEP-ADR-0001’s actual acceptance bar is a lossless roundtrip through an external CAD/PLM tool (e.g. FreeCAD), which has not been attempted — no such tool is available in this development environment. A self-roundtrip proves internal consistency (the writer and reader agree with each other); it does not prove kSTEP’s Part 21 output is actually interoperable with real-world STEP tooling, nor that this reader can parse real-world Part 21 files beyond the six V1 entity shapes it targets. Closing that gap is future work, not assumed by this wave. (M2 Welle 6, done) CLI wiring now exists: kstep export <script.kstep.kts> (via the new kstep-script module’s *.kstep.kts scripting DSL and KStepScriptHost) compiles and runs a script and writes its Part21Writer output to disk — see Status and Usage above. The external-CAD/PLM-tool roundtrip gap above is unchanged by this — `kstep export’s output still needs the same manual FreeCAD import to close acceptance criterion #2.

  5. An MCP server and an LLM benchmark comparing raw STEP, CadQuery, and the kSTEP DSL as generation targets. (M2 Welle 1) Split into two halves, only the first of which is done: the MCP server itself (kstep-mcp — fifteen tools over stdio wrapping the twelve AP242 builders and Part-21 export, a bounded session-scoped EntityStore, structured tool-call errors, tested end-to-end via the SDK’s in-memory transport — see Status and Usage above) is complete and gives an LLM agent the same "compiler/validator as oracle" structured feedback loop a Kotlin caller already gets, just over MCP tool calls instead of a Kotlin compiler error. The LLM benchmark half — actually calling an LLM API and comparing raw STEP, CadQuery, and the kSTEP DSL (via kstep-mcp) as generation targets — is real API cost and experiment-design work, deliberately not started, and deferred to an explicit discussion with the project owner rather than assumed by this wave. (M2 Welle 3, done) kstep-cli now wraps the MCP server behind a kstep mcp subcommand — see Status and Usage above. (M2 Welle 4, done) build_next_assembly_usage_occurrence now enforces NAUO’s real UNIQUE UR1 rule against the `EntityStore’s current entries — see Status and Usage above.

  6. Geometry via an OpenCascade (OCCT) bridge. (Geometrie Welle 1, started) kstep-geometry now exists: box construction, real B-Rep topology/volume readback, and AP242/AP203/AP214IS STEP export, all through a hand-written JNI shim against a system-installed OCCT — see the kstep-geometry row above and docs/adr/ADR-0005-occt-jni-bridge.adoc for the full binding-choice rationale, license findings, and the named follow-up waves (further primitives and boolean ops, STEP import, a .kstep.kts geometry DSL, merging OCCT shape data with kSTEP’s own AP242 product-structure Part 21 output, a feature/parametric-history model, multi-platform support, a desktop viewer). (Geometrie Welle 5a, done) OcctKernel now also offers extrudeProfile(…​) (2D-profile-to-solid extrusion) and fillet(…​) (edge rounding), both composable with makeBox and with each other — see the kstep-geometry row above and docs/adr/ADR-0008-occt-feature-operations.adoc for the DoS-guard measurements and the parametric-history half (Geometrie Welle 5b) this wave deliberately leaves open. (Geometrie Welle 5b, Teil 1, done) A new dev.kstep.geometry.feature subpackage (Feature/FeatureSequence/ FeatureRebuilder.rebuild(…​)) replays a value-only sequence of OcctKernel calls, proving the "change one parameter, rebuild, get a new measured result" claim on top of OcctKernel exactly as it already is — see the kstep-geometry row above and docs/adr/ADR-0015-parametric-feature-history-foundation.adoc for the scope boundary (still no stable geometric edge identity, undo/redo, feature-tree UI, or serialization — all remain open follow-ups). (Geometrie Welle 6, pulled forward, done) kstep-constraints now exists: a PlaneGCS 2D geometric constraint-solver bridge, initially solving point systems under distance constraints — see the kstep-constraints row above and docs/adr/ADR-0006-planegcs-constraint-bridge.adoc for the full license analysis and the named follow-up waves. (C-Welle 2, partial, done) Coincidence, horizontal/vertical, and point-on-line constraints added on the same points-only API — see docs/adr/ADR-0007-planegcs-additional-constraint-types.adoc for what shipped and that wave’s reduced-scope DoS measurement, continued as C-Welle 2b. (C-Welle 2c, done) Parallel and perpendicular constraints added on the same points-only API — see the kstep-constraints row above and docs/adr/ADR-0014-planegcs-parallel-and-perpendicular.adoc. Still remaining: L2LAngle/P2PAngle (angle-between-line/point-pairs) and CoordinateX/CoordinateY, deferred to C-Welle 3 alongside sketch entities. Sketch entities, an incremental/interactive solver, PlaneGCS’s own diagnosis API, a kstep-sketch module, AP242 mapping, multi-platform support, and a WASM path remain later items. Additional STEP Application Protocols beyond AP242 remain a separate, not-yet-started later item. (Geometrie Welle 4, done) A new kstep-shape module now merges a validated kstep-core AP242 product structure with an OCCT B-Rep solid into one ISO 10303-21 file (Ap242ShapeExporter.export), discarding the placeholder product structure OCCT’s own writer emits and replacing it with kSTEP’s own — see the kstep-shape row below and docs/adr/ADR-0009-ap242-shape-assignment.adoc for the full design rationale and the named follow-up waves (assembly geometry, a DSL/CLI/MCP surface, codegen-typed bridge entities, STEP import, raised DoS limits, non-ASCII escapes). This wave also grew kstep-step21 itself: the full ISO 10303-21 value grammar (integers/reals, enumerations, the * DERIVE-token, typed parameters, complex instances) and a new Part21ReadMode.TOLERANT that keeps unrecognized entities as opaque instances instead of throwing — without which OCCT’s own STEP output could not be read back through kstep-step21 at all. Part21ReadMode.STRICT remains the default and is behaviorally unchanged from before this wave. (Viewer-Welle 1, done) The "a desktop viewer" follow-up ADR-0005 named above is also done: kstep-viewer opens a real Compose Desktop window showing a triangulated OCCT shape — see the kstep-viewer row above and docs/adr/ADR-0010-occt-triangulation-and-viewer.adoc. (viewer-camera- interaction wave, done) The initial static isometric snapshot now supports drag-to-orbit, scroll-to-zoom, and a reset — see docs/adr/ADR-0012-viewer-camera-interaction.adoc. (multi-shape- composition-and-fill-light wave, done) The demo scene composes several placed shapes into one mesh under a second, additive fill light — see docs/adr/ADR-0013-multi-shape-composition-and-fill-light.adoc. (headless-preview-rendering wave, done) The same projection/rasterizer code also renders headlessly (no window, no display server) via kstep render in SVG/PNG/text — see docs/adr/ADR-0011-headless-preview- rendering.adoc. (gltf-glb-export wave, done) kstep render -f glb additionally exports a self-contained, Khronos-validator-clean binary glTF 2.0 document — see docs/adr/ADR-0016-gltf-glb-export.adoc. Still open: undo/redo, a full sketch DSL beyond the points-only constraint API above, stable geometric edge identity, and multi-platform distribution of the viewer/CLI beyond linux-x86-64.

License and attribution

kSTEP is licensed under the Apache License, Version 2.0 — see LICENSE.

The bundled EXPRESS grammar (kstep-express/src/main/antlr/dev/kstep/express/grammar/Express.g4) is vendored from lutaml/express-grammar and is BSD-2-Clause licensed (Ribose Inc.); see NOTICE for full attribution.

A small excerpt of the real AP242 EXPRESS schema (kstep-express/src/main/resources/dev/kstep/express/codegen/ap242-v1-entities.exp — nineteen declarations, mostly verbatim (two spots deliberately adapted), not the complete 2,122-entity schema) is copied from the official CAx-IF/MBx-IF-published ap242ed2_dis2_mim_lf_v1.101.exp (ISO TS 10303-442 AP242 EXPRESS MIM Long Form, v1.101, 2019); see NOTICE for full provenance, which parts are verbatim vs. adapted, and the precedent this rests on.

kstep-mcp depends on the official Model Context Protocol Kotlin SDK (io.modelcontextprotocol:kotlin-sdk-server, maintained by Anthropic in collaboration with JetBrains), which is MIT licensed — compatible with kSTEP’s Apache 2.0 license, not vendored or modified, pulled in as a normal Gradle dependency.

kstep-geometry’s native JNI shim (`kstep-geometry/src/main/cpp/kstep_occt_bridge.cpp) is compiled, at build time only, against a system-installed Open CASCADE Technology (OCCT) library (Ubuntu package libocct-*-7.9), licensed under LGPL-2.1-only with the Open CASCADE Exception 1.0 — see https://dev.opencascade.org/resources/licensing. No OCCT binary, header, or source is vendored in this repository, and this product does not currently bundle or redistribute any OCCT binary; see NOTICE and docs/adr/ADR-0005-occt-jni-bridge.adoc for the full license and provenance discussion.

About

Kotlin DSL for STEP

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages