feat: add candidate FreeCAD naming SDK and attachment oracles
Some checks failed
real-verification / chrome (push) Has been cancelled
real-verification / freecad-oracle (push) Has been cancelled
real-verification / wasm (push) Has been cancelled

Build and verify the candidate-only FreeCAD naming bridge and OCCT worker path, including three-stage StringHasher restoration. Add Datum, ShapeBinder, attachment-mode, and PartDesign structure oracles plus offline SDK build plans and CI boundary checks.
This commit is contained in:
2026-08-13 17:16:07 -04:00
parent 58c0807219
commit f97eae4153
79 changed files with 7936 additions and 77 deletions

View File

@@ -0,0 +1,783 @@
#include <emscripten/bind.h>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QString>
#include <App/Application.h>
#include <App/ElementMap.h>
#include <App/ElementNamingUtils.h>
#include <App/IndexedName.h>
#include <App/MappedName.h>
#include <App/StringHasher.h>
#include <Base/Exception.h>
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <iterator>
#include <map>
#include <memory>
#include <set>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
#ifdef __EMSCRIPTEN__
#include <pwd.h>
#include <limits.h>
#include <sys/types.h>
#include <unistd.h>
extern "C" ssize_t readlink(const char* path, char* buffer, size_t size)
{
constexpr const char* executable = "/freecad/bin/freecad-naming-bridge";
if (std::strcmp(path, "/proc/self/exe") != 0) {
return -1;
}
const size_t length = std::strlen(executable);
if (size < length) {
return -1;
}
std::memcpy(buffer, executable, length);
return static_cast<ssize_t>(length);
}
extern "C" char* realpath(const char* path, char* resolvedPath)
{
static constexpr const char* allowed[] = {
"/freecad-user",
"/freecad-user/config",
"/freecad-user/data",
"/freecad-user/cache",
"/freecad-user/temp",
};
if (!path || std::none_of(std::begin(allowed), std::end(allowed), [path](const char* entry) {
return std::strcmp(path, entry) == 0;
})) {
return nullptr;
}
const size_t length = std::strlen(path);
if (length >= PATH_MAX) {
return nullptr;
}
char* output = resolvedPath ? resolvedPath : static_cast<char*>(std::malloc(length + 1));
if (!output) {
return nullptr;
}
std::memcpy(output, path, length + 1);
return output;
}
extern "C" int getpwuid_r(uid_t uid,
struct passwd* password,
char* buffer,
size_t bufferSize,
struct passwd** result)
{
constexpr const char identity[] = "freecad\0/freecad-user\0/bin/false";
if (bufferSize < sizeof(identity)) {
*result = nullptr;
return -1;
}
std::memcpy(buffer, identity, sizeof(identity));
password->pw_name = buffer;
password->pw_passwd = const_cast<char*>("");
password->pw_uid = uid;
password->pw_gid = getgid();
password->pw_gecos = buffer;
password->pw_dir = buffer + sizeof("freecad");
password->pw_shell = password->pw_dir + sizeof("/freecad-user");
*result = password;
return 0;
}
#endif
namespace
{
constexpr int ABI_VERSION = 1;
constexpr const char* FREECAD_VERSION = "1.1.1";
constexpr const char* FREECAD_COMMIT = "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d";
void ensureApplication()
{
static bool initialized = false;
if (initialized) {
return;
}
char executable[] = "freecad-naming-bridge";
char console[] = "--console";
char* argv[] = {executable, console};
App::Application::init(2, argv);
initialized = true;
}
QJsonObject requireObject(const QJsonObject& object, const char* key)
{
const QJsonValue value = object.value(key);
if (!value.isObject()) {
throw std::runtime_error(std::string("FreeCAD naming request requires object ") + key);
}
return value.toObject();
}
QString requireString(const QJsonObject& object, const char* key)
{
const QJsonValue value = object.value(key);
if (!value.isString() || value.toString().isEmpty()) {
throw std::runtime_error(std::string("FreeCAD naming request requires string ") + key);
}
return value.toString();
}
long requirePositiveTag(const QJsonObject& object, const char* key)
{
const double value = object.value(key).toDouble(-1.0);
if (!std::isfinite(value) || value <= 0.0 || std::floor(value) != value
|| value > static_cast<double>(0x7fffffff)) {
throw std::runtime_error(std::string("FreeCAD naming request requires positive tag ") + key);
}
return static_cast<long>(value);
}
int requireIndex(const QJsonObject& object, const char* key)
{
const double value = object.value(key).toDouble(-1.0);
if (!std::isfinite(value) || value < 0.0 || std::floor(value) != value
|| value > static_cast<double>(0x7ffffffe)) {
throw std::runtime_error(std::string("FreeCAD naming request requires non-negative index ")
+ key);
}
return static_cast<int>(value);
}
QString titleKind(const QString& value)
{
if (value == "face") {
return "Face";
}
if (value == "edge") {
return "Edge";
}
if (value == "vertex") {
return "Vertex";
}
throw std::runtime_error("FreeCAD naming history supports only face, edge, and vertex");
}
QString canonicalOperation(const QString& operation)
{
QString output;
output.reserve(operation.size());
for (const QChar character : operation) {
if (character.isLetterOrNumber()) {
output.append(character.toUpper());
}
else if (character == '-') {
output.append('_');
}
else {
throw std::runtime_error("FreeCAD naming operation contains an unsupported character");
}
}
if (output.isEmpty()) {
throw std::runtime_error("FreeCAD naming operation is empty");
}
return output;
}
QJsonObject parseNameToken(const std::string& raw)
{
if (raw.empty() || (raw[0] != ':' && raw[0] != '$' && raw[0] != ';')) {
throw std::runtime_error("FreeCAD ElementMap emitted an invalid name token");
}
std::vector<std::string> parts;
std::size_t start = 0;
while (true) {
const std::size_t dot = raw.find('.', start);
parts.push_back(raw.substr(start, dot == std::string::npos ? dot : dot - start));
if (dot == std::string::npos) {
break;
}
start = dot + 1;
}
const QString marker = QString(QChar(raw[0]));
QJsonObject token {{"raw", QString::fromStdString(raw)}, {"marker", marker}};
QJsonArray suffix;
if (raw[0] == ':') {
if (parts.size() < 3) {
throw std::runtime_error("FreeCAD ElementMap emitted a truncated indexed token");
}
bool postfixOk = false;
bool indexOk = false;
const int postfixIndex = QString::fromStdString(parts[0].substr(1)).toInt(&postfixOk, 16);
const int elementIndex = QString::fromStdString(parts[1]).toInt(&indexOk, 16);
if (!postfixOk || !indexOk) {
throw std::runtime_error("FreeCAD ElementMap emitted a malformed indexed token");
}
token.insert("postfixIndex", postfixIndex);
token.insert("elementIndex", elementIndex);
for (std::size_t index = 2; index < parts.size(); ++index) {
suffix.append(QString::fromStdString(parts[index]));
}
}
else {
token.insert("name", QString::fromStdString(parts[0].substr(1)));
for (std::size_t index = 1; index < parts.size(); ++index) {
suffix.append(QString::fromStdString(parts[index]));
}
}
token.insert("suffix", suffix);
return token;
}
QJsonObject parseElementMap(const std::string& saved)
{
std::istringstream stream(saved);
unsigned rootId = 0;
int postfixCount = 0;
std::string label;
if (!(stream >> rootId >> label >> postfixCount) || rootId == 0 || label != "PostfixCount"
|| postfixCount < 0) {
throw std::runtime_error("FreeCAD ElementMap root header is invalid");
}
QJsonArray postfixes;
for (int index = 0; index < postfixCount; ++index) {
std::string postfix;
if (!(stream >> postfix)) {
throw std::runtime_error("FreeCAD ElementMap postfix list is truncated");
}
postfixes.append(QString::fromStdString(postfix));
}
int mapCount = 0;
if (!(stream >> label >> mapCount) || label != "MapCount" || mapCount <= 0) {
throw std::runtime_error("FreeCAD ElementMap map count is invalid");
}
QJsonArray maps;
int rootMapIndex = 0;
for (int mapOrdinal = 0; mapOrdinal < mapCount; ++mapOrdinal) {
int mapIndex = 0;
unsigned mapId = 0;
int typeCount = 0;
if (!(stream >> label >> mapIndex >> mapId >> typeCount) || label != "ElementMap"
|| mapIndex <= 0 || typeCount < 0) {
throw std::runtime_error("FreeCAD ElementMap map header is invalid");
}
rootMapIndex = std::max(rootMapIndex, mapIndex);
QJsonArray sections;
for (int typeOrdinal = 0; typeOrdinal < typeCount; ++typeOrdinal) {
std::string sectionName;
int childCount = 0;
if (!(stream >> sectionName >> label >> childCount) || label != "ChildCount"
|| childCount != 0) {
throw std::runtime_error("Candidate bridge does not accept child ElementMap records");
}
int nameCount = 0;
if (!(stream >> label >> nameCount) || label != "NameCount" || nameCount < 0) {
throw std::runtime_error("FreeCAD ElementMap name count is invalid");
}
QJsonArray names;
for (int nameOrdinal = 0; nameOrdinal < nameCount; ++nameOrdinal) {
QJsonArray tokens;
std::string raw;
std::string token;
while (stream >> token) {
if (token == "0") {
break;
}
if (!raw.empty()) {
raw += ' ';
}
raw += token;
tokens.append(parseNameToken(token));
}
if (!stream) {
throw std::runtime_error("FreeCAD ElementMap name entry is truncated");
}
names.append(QJsonObject {
{"tokens", tokens},
{"trailing", "0"},
{"raw", QString::fromStdString(raw.empty() ? "0" : raw + " 0")},
});
}
sections.append(QJsonObject {
{"name", QString::fromStdString(sectionName)},
{"children", QJsonArray {}},
{"names", names},
});
}
if (!(stream >> label) || label != "EndMap") {
throw std::runtime_error("FreeCAD ElementMap map terminator is missing");
}
maps.append(QJsonObject {
{"index", mapIndex},
{"id", static_cast<qint64>(mapId)},
{"typeCount", typeCount},
{"sections", sections},
});
}
return QJsonObject {
{"schemaVersion", 2},
{"nativeVersion", 1},
{"rootId", static_cast<qint64>(rootId)},
{"postfixes", postfixes},
{"maps", maps},
{"rootMapIndex", rootMapIndex},
};
}
QJsonObject stringHasherJson(const App::StringHasherRef& hasher)
{
QJsonArray entries;
for (const auto& [id, reference] : hasher->getIDMap()) {
const App::StringID& stringId = reference.deref();
int flags = 0;
flags |= stringId.isBinary() ? 1 << 0 : 0;
flags |= stringId.isHashed() ? 1 << 1 : 0;
flags |= stringId.isPostfixEncoded() ? 1 << 2 : 0;
flags |= stringId.isPostfixed() ? 1 << 3 : 0;
flags |= stringId.isIndexed() ? 1 << 4 : 0;
flags |= stringId.isPrefixID() ? 1 << 5 : 0;
flags |= stringId.isPrefixIDIndex() ? 1 << 6 : 0;
flags |= stringId.isPersistent() ? 1 << 7 : 0;
QJsonArray relatedIds;
for (const auto& related : reference.relatedIDs()) {
relatedIds.append(static_cast<qint64>(related.value()));
}
entries.append(QJsonObject {
{"id", static_cast<qint64>(id)},
{"flags", flags},
{"relatedIds", relatedIds},
{"data", QString::fromUtf8(stringId.data())},
{"postfix", QString::fromUtf8(stringId.postfix())},
});
}
return QJsonObject {{"schemaVersion", 2}, {"nativeVersion", 1}, {"entries", entries}};
}
int stringIdFlags(const App::StringID& stringId)
{
int flags = 0;
flags |= stringId.isBinary() ? 1 << 0 : 0;
flags |= stringId.isHashed() ? 1 << 1 : 0;
flags |= stringId.isPostfixEncoded() ? 1 << 2 : 0;
flags |= stringId.isPostfixed() ? 1 << 3 : 0;
flags |= stringId.isIndexed() ? 1 << 4 : 0;
flags |= stringId.isPrefixID() ? 1 << 5 : 0;
flags |= stringId.isPrefixIDIndex() ? 1 << 6 : 0;
flags |= stringId.isPersistent() ? 1 << 7 : 0;
return flags;
}
long requireStringId(const QJsonValue& value, const char* label)
{
const double number = value.toDouble(-1.0);
if (!std::isfinite(number) || number <= 0.0 || std::floor(number) != number
|| number > static_cast<double>(0x7fffffff)) {
throw std::runtime_error(std::string("Prior StringHasher requires positive ") + label);
}
return static_cast<long>(number);
}
void restoreStringHasherTable(const QJsonObject& table, const App::StringHasherRef& hasher)
{
if (table.value("schemaVersion").toInt() != 2
|| table.value("nativeVersion").toInt() != 1) {
throw std::runtime_error("Prior StringHasher schema or native version is unsupported");
}
const QJsonArray entries = table.value("entries").toArray();
if (entries.size() > 1000000) {
throw std::runtime_error("Prior StringHasher entry count exceeds the candidate limit");
}
long previousId = 0;
for (const QJsonValue entryValue : entries) {
if (!entryValue.isObject()) {
throw std::runtime_error("Prior StringHasher entry must be an object");
}
const QJsonObject entry = entryValue.toObject();
const long id = requireStringId(entry.value("id"), "entry ID");
if (id != previousId + 1) {
throw std::runtime_error("Prior StringHasher IDs must be contiguous and ordered");
}
const int flags = entry.value("flags").toInt(-1);
constexpr int knownFlags = 0xff;
if (flags < 0 || (flags & ~knownFlags) != 0) {
throw std::runtime_error("Prior StringHasher entry contains unknown flags");
}
if ((flags & ((1 << 0) | (1 << 1))) != 0) {
throw std::runtime_error(
"Candidate bridge cannot losslessly restore binary or one-way-hashed StringHasher entries");
}
if (!entry.value("data").isString() || !entry.value("postfix").isString()
|| !entry.value("relatedIds").isArray()) {
throw std::runtime_error("Prior StringHasher entry payload is incomplete");
}
const QByteArray data = entry.value("data").toString().toUtf8();
const QByteArray postfix = entry.value("postfix").toString().toUtf8();
const QJsonArray relatedValues = entry.value("relatedIds").toArray();
std::vector<long> relatedIds;
relatedIds.reserve(relatedValues.size());
for (const QJsonValue relatedValue : relatedValues) {
const long relatedId = requireStringId(relatedValue, "related ID");
if (relatedId >= id || !hasher->getID(relatedId)) {
throw std::runtime_error("Prior StringHasher related ID is unresolved or forward-referenced");
}
relatedIds.push_back(relatedId);
}
App::StringIDRef restored;
if ((flags & (1 << 3)) == 0) {
if (flags != 0 && flags != (1 << 7)) {
throw std::runtime_error("Prior non-postfixed StringHasher flags cannot be reconstructed");
}
restored = hasher->getID(data, App::StringHasher::Option::None);
}
else {
QByteArray mappedData = data;
if ((flags & ((1 << 4) | (1 << 6))) != 0) {
mappedData += '1';
}
Data::MappedName mapped(mappedData);
if (!postfix.isEmpty()) {
mapped += postfix;
}
int internalCount = 0;
internalCount += (flags & (1 << 2)) != 0 ? 1 : 0;
internalCount += (flags & (1 << 4)) != 0 ? 1 : 0;
if (internalCount > static_cast<int>(relatedIds.size())) {
throw std::runtime_error("Prior StringHasher internal dependencies are truncated");
}
Data::ElementIDRefs externalRefs;
for (std::size_t index = static_cast<std::size_t>(internalCount);
index < relatedIds.size(); ++index) {
externalRefs.push_back(hasher->getID(relatedIds[index]));
}
restored = hasher->getID(mapped, externalRefs);
}
if (!restored || restored.value() != id) {
throw std::runtime_error("Prior StringHasher entry did not restore to its persistent ID");
}
if ((flags & (1 << 7)) != 0) {
restored.setPersistent(true);
}
const App::StringID& actual = restored.deref();
std::vector<long> actualRelated;
for (const App::StringIDRef& related : actual.relatedIDs()) {
actualRelated.push_back(related.value());
}
if (stringIdFlags(actual) != flags || actual.data() != data || actual.postfix() != postfix
|| actualRelated != relatedIds) {
throw std::runtime_error("Prior StringHasher entry changed during strict restoration");
}
previousId = id;
}
}
void restorePriorStringHasher(const QJsonArray& inputs, const App::StringHasherRef& hasher)
{
QByteArray canonical;
QJsonObject selected;
for (const QJsonValue inputValue : inputs) {
const QJsonObject evidence = inputValue.toObject().value("namingEvidence").toObject();
if (evidence.isEmpty()) {
continue;
}
const QJsonValue tableValue = evidence.value("stringHasher");
if (!tableValue.isObject()) {
const QJsonArray mappedNames = evidence.value("mappedNames").toArray();
const bool requiresHasher = std::any_of(
mappedNames.begin(), mappedNames.end(), [](const QJsonValue& mappedValue) {
const QJsonObject reference = mappedValue.toObject().value("reference").toObject();
return !reference.value("stringIds").toArray().isEmpty()
|| reference.contains("prefixStringId");
});
if (requiresHasher) {
throw std::runtime_error("Prior mapped-name IDs require StringHasher evidence");
}
continue;
}
const QJsonObject table = tableValue.toObject();
const QByteArray encoded = QJsonDocument(table).toJson(QJsonDocument::Compact);
if (canonical.isEmpty()) {
canonical = encoded;
selected = table;
}
else if (canonical != encoded) {
throw std::runtime_error("Candidate bridge cannot merge inconsistent prior StringHasher tables");
}
}
if (!selected.isEmpty()) {
restoreStringHasherTable(selected, hasher);
}
}
QJsonObject findSourceInput(const QJsonArray& inputs, const QString& source)
{
for (const QJsonValue value : inputs) {
const QJsonObject input = value.toObject();
if (input.value("inputId").toString() == source || input.value("role").toString() == source) {
return input;
}
}
if (source == "object" && !inputs.isEmpty()) {
return inputs.first().toObject();
}
if (source == "tool" && inputs.size() > 1) {
return inputs.at(1).toObject();
}
throw std::runtime_error("FreeCAD naming history references an unknown input");
}
struct SourceName
{
Data::MappedName name;
QString persistentId;
};
SourceName sourceNameFor(const QJsonObject& input,
const QString& kind,
int sourceIndex,
const App::StringHasherRef& hasher)
{
const QString indexed = titleKind(kind) + QString::number(sourceIndex + 1);
const QJsonValue evidenceValue = input.value("namingEvidence");
if (!evidenceValue.isObject()) {
return {Data::MappedName(indexed.toStdString()), indexed};
}
const QJsonArray mappedNames = evidenceValue.toObject().value("mappedNames").toArray();
for (const QJsonValue value : mappedNames) {
const QJsonObject mapped = value.toObject();
if (mapped.value("kind").toString() != kind
|| mapped.value("resultIndex").toInt(-1) != sourceIndex) {
continue;
}
const QJsonObject reference = requireObject(mapped, "reference");
const QJsonArray stringIds = reference.value("stringIds").toArray();
for (const QJsonValue idValue : stringIds) {
if (!hasher->getID(requireStringId(idValue, "mapped-name string ID"))) {
throw std::runtime_error("Prior mapped-name string ID is absent from StringHasher");
}
}
if (reference.contains("prefixStringId")) {
const long prefixId = requireStringId(reference.value("prefixStringId"),
"mapped-name prefix ID");
if (!hasher->getID(prefixId)) {
throw std::runtime_error("Prior mapped-name prefix ID is absent from StringHasher");
}
}
Data::MappedName name(requireString(reference, "name").toStdString());
const QString postfix = reference.value("postfix").toString();
if (!postfix.isEmpty()) {
name += postfix.toStdString();
}
return {name, mapped.value("resultPersistentId").toString(indexed)};
}
throw std::runtime_error("Prior naming evidence has no matching source subshape");
}
QJsonObject mappedReference(const Data::MappedName& name, const Data::ElementIDRefs& stringIds)
{
QJsonArray ids;
for (const App::StringIDRef& stringId : stringIds) {
ids.append(static_cast<qint64>(stringId.value()));
}
QJsonObject reference {
{"name", QString::fromUtf8(name.dataBytes())},
{"postfix", QString::fromUtf8(name.postfixBytes())},
{"stringIds", ids},
};
const App::StringID::IndexID prefix = App::StringID::fromString(name.dataBytes());
if (prefix) {
reference.insert("prefixStringId", static_cast<qint64>(prefix.id));
}
return reference;
}
std::string evidenceJsonImpl(const std::string& requestJson)
{
ensureApplication();
QJsonParseError parseError;
const QJsonDocument document =
QJsonDocument::fromJson(QByteArray::fromStdString(requestJson), &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject()) {
throw std::runtime_error("FreeCAD naming request is not valid JSON");
}
const QJsonObject request = document.object();
if (request.value("schemaVersion").toInt() != ABI_VERSION) {
throw std::runtime_error("FreeCAD naming request schemaVersion is unsupported");
}
const QString stageId = requireString(request, "stageId");
const QString resultObjectId = requireString(request, "resultObjectId");
const long resultTag = requirePositiveTag(request, "resultObjectTag");
const QString operation = requireString(request, "operation");
const QString operationPostfix = canonicalOperation(operation);
const QJsonArray inputs = request.value("inputs").toArray();
const QJsonObject history = requireObject(request, "history");
const QJsonArray records = history.value("records").toArray();
if (inputs.isEmpty() || records.isEmpty()) {
throw std::runtime_error("FreeCAD naming request requires inputs and native history records");
}
App::StringHasherRef hasher(new App::StringHasher());
hasher->setSaveAll(true);
restorePriorStringHasher(inputs, hasher);
auto elementMap = std::make_shared<Data::ElementMap>();
elementMap->hasher = hasher;
QJsonArray mappedNames;
std::set<std::string> resultKeys;
std::map<std::string, int> sourceOrdinals;
for (const QJsonValue recordValue : records) {
const QJsonObject record = recordValue.toObject();
const QString relation = requireString(record, "relation");
if (relation == "deleted") {
continue;
}
if (relation != "modified" && relation != "generated") {
throw std::runtime_error("FreeCAD naming history relation is unsupported");
}
const QString sourceKind = requireString(record, "kind");
const QString resultKind = record.value("resultKind").toString(sourceKind);
titleKind(sourceKind);
const QString resultType = titleKind(resultKind);
const int sourceIndex = requireIndex(record, "sourceIndex");
const QString source = record.value("sourceId").toString(
requireString(record, "source"));
const QJsonObject input = findSourceInput(inputs, source);
const QString sourceObjectId = requireString(input, "objectId");
const long sourceTag = requirePositiveTag(input, "objectTag");
const SourceName sourceName = sourceNameFor(input, sourceKind, sourceIndex, hasher);
QJsonArray resultIndexes = record.value("resultIndexes").toArray();
if (resultIndexes.isEmpty() && record.contains("resultIndex")) {
resultIndexes.append(record.value("resultIndex"));
}
if (resultIndexes.isEmpty()) {
throw std::runtime_error("Non-deleted FreeCAD naming history requires result indexes");
}
const std::string ordinalKey = (sourceObjectId + '|' + sourceKind + '|'
+ QString::number(sourceIndex) + '|' + relation)
.toStdString();
for (const QJsonValue resultValue : resultIndexes) {
const int resultIndex = requireIndex(QJsonObject {{"value", resultValue}}, "value");
const std::string resultKey =
(resultKind + ':' + QString::number(resultIndex)).toStdString();
if (!resultKeys.insert(resultKey).second) {
throw std::runtime_error("FreeCAD naming history maps the same result subshape twice");
}
const int ordinal = ++sourceOrdinals[ordinalKey];
std::ostringstream postfix;
postfix << (relation == "modified" ? Data::POSTFIX_MOD : Data::POSTFIX_GEN);
if (ordinal > 1) {
postfix << ordinal;
}
Data::MappedName encoded(sourceName.name);
Data::ElementIDRefs stringIds;
elementMap->encodeElementName(
resultType.at(0).toLatin1(),
encoded,
postfix,
&stringIds,
resultTag,
operationPostfix.toUtf8().constData(),
sourceTag);
const Data::IndexedName resultIndexed(
(resultType + QString::number(resultIndex + 1)).toUtf8());
const Data::MappedName stored =
elementMap->setElementName(resultIndexed, encoded, resultTag, &stringIds);
if (!stored) {
throw std::runtime_error("FreeCAD ElementMap rejected a native history mapping");
}
mappedNames.append(QJsonObject {
{"kind", resultKind},
{"resultIndex", resultIndex},
{"resultPersistentId", resultType + QString::number(resultIndex + 1)},
{"relation", relation},
{"reference", mappedReference(stored, stringIds)},
{"sourceRefs", QJsonArray {QJsonObject {
{"objectId", sourceObjectId},
{"persistentId", sourceName.persistentId},
{"stageId", input.value("stageId")},
}}},
});
}
}
if (mappedNames.isEmpty()) {
throw std::runtime_error("FreeCAD naming history has no result subshape evidence");
}
elementMap->beforeSave(hasher);
std::ostringstream saved;
elementMap->save(saved);
const QJsonObject response {
{"schemaVersion", ABI_VERSION},
{"stageId", stageId},
{"resultObjectId", resultObjectId},
{"status", "native-evidence"},
{"mappedNames", mappedNames},
{"stringHasher", stringHasherJson(hasher)},
{"elementMap2", parseElementMap(saved.str())},
};
return QJsonDocument(response).toJson(QJsonDocument::Compact).toStdString();
}
std::string evidenceJson(const std::string& requestJson)
{
try {
return evidenceJsonImpl(requestJson);
}
catch (const Base::Exception& exception) {
return QJsonDocument(QJsonObject {
{"schemaVersion", ABI_VERSION},
{"status", "error"},
{"error", QString::fromStdString("FreeCAD private naming failure: "
+ exception.getMessage())},
}).toJson(QJsonDocument::Compact).toStdString();
}
catch (const std::exception& exception) {
return QJsonDocument(QJsonObject {
{"schemaVersion", ABI_VERSION},
{"status", "error"},
{"error", QString::fromUtf8(exception.what())},
}).toJson(QJsonDocument::Compact).toStdString();
}
catch (...) {
return R"({"schemaVersion":1,"status":"error","error":"Unknown native exception"})";
}
}
int freecadNamingAbiVersion()
{
return ABI_VERSION;
}
std::string freecadNamingCapabilitiesJson()
{
return QJsonDocument(QJsonObject {
{"schemaVersion", ABI_VERSION},
{"freecadVersion", FREECAD_VERSION},
{"sourceCommit", FREECAD_COMMIT},
{"mappedNameRef", true},
{"stringHasher", true},
{"elementMap2", true},
{"operations", QJsonArray {
"fuse", "cut", "common", "rotate", "pad", "pocket", "loft", "pipe",
"revolution", "groove", "fillet", "chamfer", "hole", "draft", "thickness",
"linear-pattern", "polar-pattern", "mirrored", "multi-transform",
}},
{"maxRequestBytes", 16 * 1024 * 1024},
{"maxResponseBytes", 32 * 1024 * 1024},
}).toJson(QJsonDocument::Compact).toStdString();
}
} // namespace
EMSCRIPTEN_BINDINGS(freecad_private_naming_bridge)
{
emscripten::function("freecadNamingAbiVersion", &freecadNamingAbiVersion);
emscripten::function("freecadNamingCapabilitiesJson", &freecadNamingCapabilitiesJson);
emscripten::function("freecadNamingEvidenceJson", &evidenceJson);
}

View File

@@ -0,0 +1,26 @@
Module.preRun = Module.preRun || [];
Module.preRun.push(() => {
for (const path of [
'/freecad-user',
'/freecad-user/config',
'/freecad-user/data',
'/freecad-user/cache',
'/freecad-user/temp',
]) {
try {
FS.mkdir(path)
} catch (error) {
if (!FS.analyzePath(path).exists) throw error
}
}
Object.assign(ENV, {
HOME: '/freecad-user',
FREECAD_USER_HOME: '/freecad-user',
FREECAD_USER_DATA: '/freecad-user/data',
FREECAD_USER_TEMP: '/freecad-user/temp',
XDG_CONFIG_HOME: '/freecad-user/config',
XDG_DATA_HOME: '/freecad-user/data',
XDG_CACHE_HOME: '/freecad-user/cache',
TMPDIR: '/freecad-user/temp',
})
})

View File

@@ -0,0 +1,185 @@
import createBridge from './dist/freecad-private-naming-bridge.js'
import { fileURLToPath } from 'node:url'
import { createHash } from 'node:crypto'
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import {
captureFreeCadPrivateNamingEvidence,
createFreeCadPrivateNamingAbiRequest,
probeFreeCadPrivateNamingAbi,
type NativeFreeCadNamingAbiModule,
} from '../../src/facade/nativeNamingAbi'
const distUrl = new URL('./dist/', import.meta.url)
const bridge = await createBridge({ locateFile: (path: string) => fileURLToPath(new URL(path, distUrl)) }) as NativeFreeCadNamingAbiModule
const probe = probeFreeCadPrivateNamingAbi(bridge)
if (probe.availability !== 'available') throw new Error(`Isolated bridge ABI probe failed: ${probe.reason}`)
const request = createFreeCadPrivateNamingAbiRequest({
requestId: 'isolated-bridge-1',
documentId: 'isolated-document',
documentVersion: 1,
operationId: 'isolated-cut-1',
operation: 'cut',
stageId: 'isolated:stage:1',
resultObjectId: 'isolated:result:1',
resultObjectTag: 99,
inputs: [
{ inputId: 'object', objectId: 'source-object', role: 'object', step: 'ISO-10303-21; object', objectTag: 42 },
{ inputId: 'tool', objectId: 'source-tool', role: 'tool', step: 'ISO-10303-21; tool', objectTag: 43 },
],
stages: [{ stageId: 'isolated:stage:1', operation: 'cut', inputIds: ['object', 'tool'], ordinal: 0 }],
resultStep: 'ISO-10303-21; result',
history: {
provider: 'occt-native',
occtVersion: '8.0.0',
hasModified: true,
hasGenerated: false,
hasDeleted: false,
records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }],
},
})
const first = captureFreeCadPrivateNamingEvidence(bridge, request, probe)
if (!first || first.status !== 'native-evidence' || first.mappedNames?.length !== 1 || !first.elementMap2 || !first.stringHasher) throw new Error('Isolated bridge returned incomplete first-stage evidence.')
if (!first.mappedNames[0].reference.postfix?.includes(';:M;CUT;:H2a:7,F')) throw new Error(`Unexpected first-stage FreeCAD postfix: ${first.mappedNames[0].reference.postfix}`)
const chained = captureFreeCadPrivateNamingEvidence(bridge, createFreeCadPrivateNamingAbiRequest({
requestId: 'isolated-bridge-2',
documentId: 'isolated-document',
documentVersion: 2,
operationId: 'isolated-cut-2',
operation: 'cut',
stageId: 'isolated:stage:2',
resultObjectId: 'isolated:result:2',
resultObjectTag: 100,
inputs: [{ inputId: 'object', objectId: 'isolated:result:1', role: 'object', stageId: 'isolated:stage:1', step: 'ISO-10303-21; first result', objectTag: 99, namingEvidence: first }],
stages: [{ stageId: 'isolated:stage:2', operation: 'cut', inputIds: ['object'], ordinal: 0 }],
resultStep: 'ISO-10303-21; second result',
history: {
provider: 'occt-native',
occtVersion: '8.0.0',
hasModified: true,
hasGenerated: false,
hasDeleted: false,
records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 1 }],
},
}), probe)
if (!chained || chained.elementMap2?.maps.length !== 1) throw new Error('Isolated bridge did not preserve chained ElementMap2 evidence.')
const chainedReference = chained.mappedNames?.[0].reference
const prefixStringId = chainedReference?.prefixStringId
const stringHasherIds = new Set(chained.stringHasher?.entries.map(({ id }) => id))
const chainedTokens = chained.elementMap2.maps.flatMap((map) => map.sections.flatMap((section) => section.names.flatMap((name) => name.tokens)))
if (!chainedReference?.name.startsWith('#') || !Number.isSafeInteger(prefixStringId) || !chainedReference.stringIds?.includes(prefixStringId!) || !stringHasherIds.has(prefixStringId!) || !chainedTokens.some((token) => token.marker === '$' && token.name === chainedReference.name)) throw new Error('Isolated bridge did not emit a closed native hashed MappedNameRef/StringHasher/ElementMap2 chain.')
const third = captureFreeCadPrivateNamingEvidence(bridge, createFreeCadPrivateNamingAbiRequest({
requestId: 'isolated-bridge-3',
documentId: 'isolated-document',
documentVersion: 3,
operationId: 'isolated-cut-3',
operation: 'cut',
stageId: 'isolated:stage:3',
resultObjectId: 'isolated:result:3',
resultObjectTag: 101,
inputs: [{ inputId: 'object', objectId: 'isolated:result:2', role: 'object', stageId: 'isolated:stage:2', step: 'ISO-10303-21; second result', objectTag: 100, namingEvidence: chained }],
stages: [{ stageId: 'isolated:stage:3', operation: 'cut', inputIds: ['object'], ordinal: 0 }],
resultStep: 'ISO-10303-21; third result',
history: {
provider: 'occt-native',
occtVersion: '8.0.0',
hasModified: true,
hasGenerated: false,
hasDeleted: false,
records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 1, resultIndex: 2 }],
},
}), probe)
const thirdReference = third?.mappedNames?.[0]?.reference
const thirdHasherIds = new Set(third?.stringHasher?.entries.map(({ id }) => id))
if (!third || third.status !== 'native-evidence' || !thirdReference?.name.startsWith('#') || !thirdReference.stringIds?.every((id) => thirdHasherIds.has(id)) || (third.stringHasher?.entries.length ?? 0) <= (chained.stringHasher?.entries.length ?? 0)) throw new Error('Isolated bridge did not restore prior StringHasher evidence for a third naming stage.')
let tamperedHasherRejected = false
try {
const validRequest = createFreeCadPrivateNamingAbiRequest({
requestId: 'isolated-bridge-tampered',
documentId: 'isolated-document',
documentVersion: 3,
operationId: 'isolated-cut-tampered',
operation: 'cut',
stageId: 'isolated:stage:tampered',
resultObjectId: 'isolated:result:tampered',
resultObjectTag: 102,
inputs: [{ inputId: 'object', objectId: 'isolated:result:2', role: 'object', stageId: 'isolated:stage:2', step: 'ISO-10303-21; second result', objectTag: 100, namingEvidence: chained }],
stages: [{ stageId: 'isolated:stage:tampered', operation: 'cut', inputIds: ['object'], ordinal: 0 }],
resultStep: 'ISO-10303-21; tampered result',
history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 1, resultIndex: 2 }] },
})
const tamperedRequest = structuredClone(validRequest)
const tamperedEntry = tamperedRequest.inputs[0].namingEvidence?.stringHasher?.entries[0]
if (!tamperedEntry) throw new Error('Chained evidence has no StringHasher entry to tamper.')
tamperedEntry.id += 1
const response = JSON.parse(bridge.freecadNamingEvidenceJson!(JSON.stringify(tamperedRequest))) as { status?: string; error?: string }
if (response.status !== 'error' || !response.error?.includes('StringHasher IDs must be contiguous and ordered')) throw new Error(`Unexpected tampered StringHasher response: ${JSON.stringify(response)}`)
tamperedHasherRejected = true
} catch (error) {
throw error
}
if (!tamperedHasherRejected) throw new Error('Isolated bridge accepted tampered prior StringHasher evidence.')
let inconsistentTablesRejected = false
{
const inconsistentRequest = createFreeCadPrivateNamingAbiRequest({
requestId: 'isolated-bridge-inconsistent',
documentId: 'isolated-document',
documentVersion: 3,
operationId: 'isolated-cut-inconsistent',
operation: 'cut',
stageId: 'isolated:stage:inconsistent',
resultObjectId: 'isolated:result:inconsistent',
resultObjectTag: 103,
inputs: [
{ inputId: 'object', objectId: 'isolated:result:2', role: 'object', stageId: 'isolated:stage:2', step: 'ISO-10303-21; second result', objectTag: 100, namingEvidence: chained },
{ inputId: 'tool', objectId: 'isolated:result:2-copy', role: 'tool', stageId: 'isolated:stage:2', step: 'ISO-10303-21; second result copy', objectTag: 104, namingEvidence: chained },
],
stages: [{ stageId: 'isolated:stage:inconsistent', operation: 'cut', inputIds: ['object', 'tool'], ordinal: 0 }],
resultStep: 'ISO-10303-21; inconsistent result',
history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 1, resultIndex: 2 }] },
})
const secondTable = inconsistentRequest.inputs[1].namingEvidence?.stringHasher
if (!secondTable?.entries[0]) throw new Error('Chained evidence has no StringHasher table to make inconsistent.')
secondTable.entries[0].data += '-different'
const response = JSON.parse(bridge.freecadNamingEvidenceJson!(JSON.stringify(inconsistentRequest))) as { status?: string; error?: string }
if (response.status !== 'error' || !response.error?.includes('cannot merge inconsistent prior StringHasher tables')) throw new Error(`Unexpected inconsistent StringHasher response: ${JSON.stringify(response)}`)
inconsistentTablesRejected = true
}
let rejected = false
try {
captureFreeCadPrivateNamingEvidence(bridge, { ...request, history: { ...request.history, records: [] } }, probe)
} catch (error) {
if (!(error instanceof Error) || !error.message.includes('requires inputs and native history records')) throw error
rejected = true
}
if (!rejected) throw new Error('Isolated bridge accepted a request without native history records.')
const result = {
status: 'freecad-private-naming-isolated-bridge-pass',
descriptor: probe.descriptor,
firstStage: { mappedNames: first.mappedNames.length, stringHasherEntries: first.stringHasher.entries.length },
chainedStage: { mappedNames: chained.mappedNames.length, stringHasherEntries: chained.stringHasher.entries.length },
thirdStage: { mappedNames: third.mappedNames.length, stringHasherEntries: third.stringHasher.entries.length },
tamperedHasherRejected,
inconsistentTablesRejected,
productionPublication: false,
productionWorkerLinked: false,
}
const reportPath = process.env.FREECAD_NAMING_BRIDGE_REPORT
if (reportPath) {
const artifacts = await Promise.all(['freecad-private-naming-bridge.js', 'freecad-private-naming-bridge.wasm', 'freecad-private-naming-bridge.data'].map(async (name) => {
const path = fileURLToPath(new URL(name, distUrl))
const [bytes, content] = await Promise.all([stat(path).then(({ size }) => size), readFile(path)])
return { name, bytes, sha256: createHash('sha256').update(content).digest('hex') }
}))
const absoluteReportPath = resolve(reportPath)
await mkdir(dirname(absoluteReportPath), { recursive: true })
await writeFile(absoluteReportPath, `${JSON.stringify({ schemaVersion: 1, ...result, artifacts }, null, 2)}\n`)
}
console.log(JSON.stringify(result, null, 2))