@helix3/helix-cli 0.1.14-helix3.200 → 0.1.14-helix3.201
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugins/beamng-vehicle/README.md +4 -2
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_expression.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_jbeam.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_materials.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/beamng-vehicle.mjs +166 -4
- package/plugins/beamng-vehicle/bin/beamng_expression.py +373 -0
- package/plugins/beamng-vehicle/bin/lower-beamng.py +542 -16
- package/plugins/beamng-vehicle/bridge-plugin.json +2 -2
- package/plugins/beamng-vehicle/lib/vehicle-source.mjs +1 -1
package/package.json
CHANGED
|
@@ -41,8 +41,10 @@ External-plugin execution still requires the Bridge plan key and all permissions
|
|
|
41
41
|
|
|
42
42
|
The converter reads all four BeamNG material stages. Later authored stages win, while conflicts, declaration provenance, null sentinels, unknown maps, and dynamic `@...` render targets remain explicit diagnostics. Texture resolution is deterministic: selected vehicle files, then mod files, then the reviewed base-game common snapshot; same-tier ambiguity refuses the binding. This parser is packaged with the plugin and never imports engine-repository code at runtime.
|
|
43
43
|
|
|
44
|
-
Each successful terminal package emits
|
|
44
|
+
Each successful terminal package emits six assets: the canonical GLB, `<vehicle>.vehicle-source.json`, the versioned `<vehicle>.vehicle-lowering.json` diagnostic aggregate, `<vehicle>.vehicle-package.json`, `<vehicle>.vehicle-addons.json`, and `<vehicle>.vehicle-build.json`. The lowering aggregate makes source semantics, structural exclusions, runtime blockers, functional-domain status, and provenance available to CLI and agent consumers instead of discarding them after packaging. The source manifest remains embedded in GLB extras and records the exact configuration and slot closure, fingerprints, material-resolution provenance, and coverage ledger.
|
|
45
45
|
|
|
46
|
-
`lower-beamng.py` compiles the exact selected `.pc` and admitted JBeam closure into `helix.vehicle-package/2`, stable add-on records, and a source Build. Source literals cover the torque curve, idle/limiter, engine losses, gearbox and final drive, differentials, lights/electrics, audio event references, seats/cameras, and rigid openables when present.
|
|
46
|
+
`lower-beamng.py` compiles the exact selected `.pc` and admitted JBeam closure into `helix.vehicle-package/2`, stable add-on records, and a source Build. Source literals cover the torque curve, idle/limiter, engine losses, gearbox and final drive, differentials, selected front/rear `pressureWheels` brake and parking-brake values, lights/electrics, audio event references, seats/cameras, and rigid openables when present. Brake and suspension expressions use a bounded data-only resolver for selected numeric variables, arithmetic, comparisons, boolean conditionals, and `case()`; direct selected-variable references are supported, source code is never evaluated, and unknown variables, calls, or tokens remain explicitly unsupported. HELIX total brake torque is derived from BeamNG's per-wheel axle values and the exact `.pc` variable overrides take precedence over selected JBeam defaults.
|
|
47
|
+
|
|
48
|
+
Selected suspension evidence is lowered only from the chosen strut/coilover leaf part on each axle. Matching left/right spring, slow/fast bump, slow/fast rebound, velocity-split, precompression, and bound rows are retained with beam-space frequency and damping-ratio derivations. Structural parent/subframe beams and swaybar construction beams are explicitly excluded by part custody. Selected torsionbar stiffness is retained in `N.m/rad`, but the runtime suspension remains fail-closed: BeamNG element rates need motion-ratio geometry to become HELIX wheel rates, and the current package has one global damping triplet with no axle-specific fast/slow channels. The emitted HELIX suspension therefore remains an explicit fallback and `functionalDomains.suspension` remains false until those exact runtime mappings exist. Tyres, raw Lua/controller, and other unsupported expressions remain explicit provenance instead of being presented as literal runtime parity. Soft-body deformation, fluids, wear, and detailed damage remain out of scope. Rigid panel poses are semantic on every device, physical bodies begin at `medium`, and detachment remains disabled on every production tier while source break groups are retained as future eligibility. Simulation, authority, package identity, and Build selection are quality-invariant.
|
|
47
49
|
|
|
48
50
|
The engine consumer seam is `vehiclePackageToTuning(package, { landSolver: 'v2' })`. The source Build intentionally contains stable source identities rather than invented Continuum CIDs; after publication resolves immutable Package references, the existing `createPrivateBuildDraft` → `freezeBuildDraft` workflow is the canonical Build seam.
|
|
Binary file
|
|
Binary file
|
|
@@ -514,6 +514,167 @@ function assertValidVehiclePackage(pkg, boundary) {
|
|
|
514
514
|
}
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
function record(value) {
|
|
518
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function assertValidFunctionalLowering(lowering, boundary) {
|
|
522
|
+
const semantics = lowering?.sourceSemantics;
|
|
523
|
+
const suspension = semantics?.suspension;
|
|
524
|
+
const coverage = lowering?.coverage;
|
|
525
|
+
const suspensionCounts = suspension?.counts;
|
|
526
|
+
const suspensionProvenance = coverage?.suspensionProvenance;
|
|
527
|
+
const requiredSuspensionCounts = ['selectedLeafParts', 'excludedStructuralParts', 'resolvedAxles', 'sourceRows'];
|
|
528
|
+
const requiredProvenanceCounts = ['source-literal', 'derived', 'defaulted', 'unsupported'];
|
|
529
|
+
const nonempty = (value) => typeof value === 'string' && value.length > 0;
|
|
530
|
+
const explicitUnsupported = (value) => record(value) && value.status === 'unsupported' && nonempty(value.reason);
|
|
531
|
+
const semanticReceipt = (value) => record(value)
|
|
532
|
+
&& ['literal', 'derived', 'unsupported'].includes(value.status) && nonempty(value.unit)
|
|
533
|
+
&& Array.isArray(value.variables)
|
|
534
|
+
&& (value.status === 'unsupported' ? nonempty(value.reason) : Number.isFinite(value.value));
|
|
535
|
+
const selectedReceipt = (value) => semanticReceipt(value) && (value.status === 'unsupported'
|
|
536
|
+
|| (Object.hasOwn(value, 'raw') && value.symmetricRows === 2));
|
|
537
|
+
const dampingReceipt = (value) => semanticReceipt(value) && (value.status === 'unsupported'
|
|
538
|
+
|| (Object.hasOwn(value, 'raw') && value.symmetricRows === 2
|
|
539
|
+
&& semanticReceipt(value.velocitySplit) && Object.hasOwn(value.velocitySplit, 'raw')
|
|
540
|
+
&& value.velocitySplit.symmetricRows === 2 && value.velocitySplit.unit === 'm/s'
|
|
541
|
+
&& value.velocitySplit.status !== 'unsupported' && value.velocitySplit.value >= 0));
|
|
542
|
+
const torsionReceipt = (value) => semanticReceipt(value)
|
|
543
|
+
&& (value.status === 'unsupported' || Object.hasOwn(value, 'raw'));
|
|
544
|
+
const suspensionAxle = (value) => {
|
|
545
|
+
if (explicitUnsupported(value) && Array.isArray(value.candidates)) return true;
|
|
546
|
+
if (!record(value) || !record(value.damping)) return false;
|
|
547
|
+
const receipts = [value.springRate, value.precompressionRange, value.bumpLimit,
|
|
548
|
+
...['slowBump', 'fastBump', 'slowRebound', 'fastRebound'].map((name) => value.damping[name])];
|
|
549
|
+
const receiptShapeValid = receipts.slice(0, 3).every(selectedReceipt)
|
|
550
|
+
&& receipts.slice(3).every(dampingReceipt);
|
|
551
|
+
const expectedStatus = receipts.every((receipt) => receipt?.status !== 'unsupported')
|
|
552
|
+
? 'resolved' : 'unsupported';
|
|
553
|
+
return value.status === expectedStatus
|
|
554
|
+
&& nonempty(value.slotType) && nonempty(value.partName) && nonempty(value.source)
|
|
555
|
+
&& receiptShapeValid && record(value.derived) && record(value.rowCounts)
|
|
556
|
+
&& ['spring', 'damper', 'travelLimit'].every((name) => Number.isInteger(value.rowCounts[name])
|
|
557
|
+
&& value.rowCounts[name] >= 0);
|
|
558
|
+
};
|
|
559
|
+
const antiRollAxle = (value) => explicitUnsupported(value) || (record(value)
|
|
560
|
+
&& nonempty(value.slotType) && nonempty(value.partName) && nonempty(value.source)
|
|
561
|
+
&& torsionReceipt(value.torsionSpring) && value.runtimeStatus === 'unsupported' && nonempty(value.reason));
|
|
562
|
+
const sourceExtractionComplete = suspension?.axles?.front?.status === 'resolved'
|
|
563
|
+
&& suspension?.axles?.rear?.status === 'resolved'
|
|
564
|
+
&& suspension?.antiRoll?.front?.torsionSpring?.status !== 'unsupported'
|
|
565
|
+
&& suspension?.antiRoll?.front?.torsionSpring !== undefined
|
|
566
|
+
&& suspension?.antiRoll?.rear?.torsionSpring?.status !== 'unsupported'
|
|
567
|
+
&& suspension?.antiRoll?.rear?.torsionSpring !== undefined;
|
|
568
|
+
const countedSuspensionProvenance = Object.fromEntries(requiredProvenanceCounts.map((disposition) => [
|
|
569
|
+
disposition,
|
|
570
|
+
Array.isArray(lowering?.provenance) ? lowering.provenance.filter((row) => record(row)
|
|
571
|
+
&& row.disposition === disposition
|
|
572
|
+
&& typeof row.target === 'string'
|
|
573
|
+
&& (row.target.startsWith('physics.suspension')
|
|
574
|
+
|| row.target.startsWith('sourceSemantics.suspension'))).length : -1,
|
|
575
|
+
]));
|
|
576
|
+
const requiredSemanticTargets = new Map();
|
|
577
|
+
const receiptDisposition = (receipt) => receipt?.status === 'literal' ? 'source-literal' : receipt?.status;
|
|
578
|
+
const expectedEvidence = (receipt, source) => ({ disposition: receiptDisposition(receipt), source });
|
|
579
|
+
for (const axle of ['front', 'rear']) {
|
|
580
|
+
const row = suspension?.axles?.[axle];
|
|
581
|
+
if (record(row) && nonempty(row.partName)) {
|
|
582
|
+
requiredSemanticTargets.set(`sourceSemantics.suspension.axles.${axle}.springRate`,
|
|
583
|
+
expectedEvidence(row.springRate, row.source));
|
|
584
|
+
requiredSemanticTargets.set(`sourceSemantics.suspension.axles.${axle}.precompressionRange`,
|
|
585
|
+
expectedEvidence(row.precompressionRange, row.source));
|
|
586
|
+
requiredSemanticTargets.set(`sourceSemantics.suspension.axles.${axle}.bumpLimit`,
|
|
587
|
+
expectedEvidence(row.bumpLimit, row.source));
|
|
588
|
+
for (const field of ['slowBump', 'fastBump', 'slowRebound', 'fastRebound']) {
|
|
589
|
+
requiredSemanticTargets.set(`sourceSemantics.suspension.axles.${axle}.damping.${field}`,
|
|
590
|
+
expectedEvidence(row.damping?.[field], row.source));
|
|
591
|
+
}
|
|
592
|
+
if (Object.keys(row.derived ?? {}).length > 0) {
|
|
593
|
+
requiredSemanticTargets.set(`sourceSemantics.suspension.axles.${axle}.derived`,
|
|
594
|
+
{ disposition: 'derived', source: row.source });
|
|
595
|
+
}
|
|
596
|
+
} else if (explicitUnsupported(row)) {
|
|
597
|
+
requiredSemanticTargets.set(`sourceSemantics.suspension.axles.${axle}`,
|
|
598
|
+
{ disposition: 'unsupported', source: 'selected JBeam part custody' });
|
|
599
|
+
}
|
|
600
|
+
if (record(suspension?.antiRoll?.[axle]) && nonempty(suspension.antiRoll[axle].partName)) {
|
|
601
|
+
requiredSemanticTargets.set(`sourceSemantics.suspension.antiRoll.${axle}.torsionSpring`,
|
|
602
|
+
expectedEvidence(suspension.antiRoll[axle].torsionSpring, suspension.antiRoll[axle].source));
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
const provenanceByTarget = new Map();
|
|
606
|
+
if (Array.isArray(lowering?.provenance)) {
|
|
607
|
+
for (const row of lowering.provenance) {
|
|
608
|
+
if (record(row) && typeof row.target === 'string') {
|
|
609
|
+
const rows = provenanceByTarget.get(row.target) ?? [];
|
|
610
|
+
rows.push(row);
|
|
611
|
+
provenanceByTarget.set(row.target, rows);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
const validSemanticTargets = [...requiredSemanticTargets].every(([target, expected]) => {
|
|
616
|
+
const rows = provenanceByTarget.get(target);
|
|
617
|
+
return rows?.length === 1 && rows[0].disposition === expected.disposition
|
|
618
|
+
&& rows[0].source === expected.source;
|
|
619
|
+
});
|
|
620
|
+
const axleRows = [suspension?.axles?.front, suspension?.axles?.rear];
|
|
621
|
+
const expectedSuspensionCounts = {
|
|
622
|
+
selectedLeafParts: axleRows.reduce((count, row) => count + (Array.isArray(row?.candidates)
|
|
623
|
+
? row.candidates.length : record(row) && nonempty(row.partName) ? 1 : 0), 0),
|
|
624
|
+
excludedStructuralParts: Array.isArray(suspension?.excludedStructuralParts)
|
|
625
|
+
? suspension.excludedStructuralParts.length : -1,
|
|
626
|
+
resolvedAxles: axleRows.filter((row) => row?.status === 'resolved').length,
|
|
627
|
+
sourceRows: axleRows.reduce((count, row) => count + (record(row?.rowCounts)
|
|
628
|
+
? Object.values(row.rowCounts).reduce((sum, value) => sum + (Number.isInteger(value) ? value : 0), 0) : 0), 0),
|
|
629
|
+
};
|
|
630
|
+
const validSemantics = record(semantics)
|
|
631
|
+
&& record(semantics.mass) && Number.isInteger(semantics.mass.uniqueNodes)
|
|
632
|
+
&& Number.isFinite(semantics.mass.selectedNodeMassKg)
|
|
633
|
+
&& Array.isArray(semantics.seats)
|
|
634
|
+
&& Array.isArray(semantics.cameras)
|
|
635
|
+
&& record(semantics.brakes) && record(semantics.brakes.axles)
|
|
636
|
+
&& record(suspension)
|
|
637
|
+
&& record(suspension.axles) && suspensionAxle(suspension.axles.front) && suspensionAxle(suspension.axles.rear)
|
|
638
|
+
&& record(suspension.antiRoll) && antiRollAxle(suspension.antiRoll.front) && antiRollAxle(suspension.antiRoll.rear)
|
|
639
|
+
&& Array.isArray(suspension.excludedStructuralParts)
|
|
640
|
+
&& Array.isArray(suspension.runtimeBlockers) && suspension.runtimeBlockers.length > 0
|
|
641
|
+
&& typeof suspension.extractionComplete === 'boolean'
|
|
642
|
+
&& suspension.extractionComplete === sourceExtractionComplete
|
|
643
|
+
&& record(suspensionCounts)
|
|
644
|
+
&& requiredSuspensionCounts.every((name) => Number.isInteger(suspensionCounts[name])
|
|
645
|
+
&& suspensionCounts[name] >= 0 && suspensionCounts[name] === expectedSuspensionCounts[name])
|
|
646
|
+
&& record(semantics.wheelsTyres) && Array.isArray(semantics.wheelsTyres.selectedParts)
|
|
647
|
+
&& Array.isArray(semantics.wheelsTyres.literalRadii)
|
|
648
|
+
&& Array.isArray(semantics.electrics)
|
|
649
|
+
&& Array.isArray(semantics.audioEvents)
|
|
650
|
+
&& Array.isArray(semantics.openableControllers);
|
|
651
|
+
const validCoverage = record(coverage)
|
|
652
|
+
&& record(coverage.functionalDomains)
|
|
653
|
+
&& coverage.functionalDomains.suspension === false
|
|
654
|
+
&& record(suspensionProvenance)
|
|
655
|
+
&& requiredProvenanceCounts.every((name) => Number.isInteger(suspensionProvenance[name])
|
|
656
|
+
&& suspensionProvenance[name] >= 0
|
|
657
|
+
&& suspensionProvenance[name] === countedSuspensionProvenance[name]);
|
|
658
|
+
const validCapabilities = Array.isArray(lowering?.capabilities) && lowering.capabilities.length > 0
|
|
659
|
+
&& lowering.capabilities.every((row) => record(row) && typeof row.capability === 'string'
|
|
660
|
+
&& typeof row.disposition === 'string' && Array.isArray(row.source) && typeof row.note === 'string');
|
|
661
|
+
const validProvenance = Array.isArray(lowering?.provenance) && lowering.provenance.length > 0
|
|
662
|
+
&& lowering.provenance.every((row) => record(row) && typeof row.target === 'string'
|
|
663
|
+
&& typeof row.source === 'string'
|
|
664
|
+
&& ['source-literal', 'derived', 'defaulted', 'unsupported', 'out-of-scope',
|
|
665
|
+
'missing-source', 'discovered-not-lowered'].includes(row.disposition)
|
|
666
|
+
&& typeof row.detail === 'string');
|
|
667
|
+
const invalid = [
|
|
668
|
+
[lowering?.schemaVersion === 'helix.beamng-functional-lowering/2', 'schema'],
|
|
669
|
+
[validSemantics, 'sourceSemantics'], [validCapabilities, 'capabilities'],
|
|
670
|
+
[validProvenance, 'provenance'], [validSemanticTargets, 'semanticProvenance'],
|
|
671
|
+
[validCoverage, 'coverage'],
|
|
672
|
+
].filter(([valid]) => !valid).map(([, name]) => name);
|
|
673
|
+
if (invalid.length > 0) {
|
|
674
|
+
throw new Error(`${boundary} has an incompatible schema or incomplete helix.beamng-functional-lowering/2 diagnostics: ${invalid.join(', ')}`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
517
678
|
function lowerFunctionalVehicle({ source, candidate, sourceManifest, conversion, outputDir, commonRoot,
|
|
518
679
|
selectedPartIndexArtifact }) {
|
|
519
680
|
const configurationFile = conversion.importQuality?.configuration?.file;
|
|
@@ -543,8 +704,8 @@ function lowerFunctionalVehicle({ source, candidate, sourceManifest, conversion,
|
|
|
543
704
|
rmSync(sourcePath, { force: true });
|
|
544
705
|
rmSync(geometryPath, { force: true });
|
|
545
706
|
}
|
|
546
|
-
|
|
547
|
-
|
|
707
|
+
assertValidFunctionalLowering(bundle, 'functional BeamNG lowerer');
|
|
708
|
+
if (bundle?.vehiclePackage?.schemaVersion !== 'helix.vehicle-package/2'
|
|
548
709
|
|| bundle?.addOnPackages?.schemaVersion !== 'helix.vehicle-addon-packages/1'
|
|
549
710
|
|| bundle?.build?.schemaVersion !== 'helix.vehicle-build-source/1'
|
|
550
711
|
|| bundle?.identity?.candidateId !== candidate.id
|
|
@@ -563,8 +724,8 @@ function packageVehicleSource(request) {
|
|
|
563
724
|
}
|
|
564
725
|
const functional = input?.metadata?.functionalVehicle;
|
|
565
726
|
const candidateId = portableCandidateId(manifest?.vehicle?.candidateId);
|
|
566
|
-
|
|
567
|
-
|
|
727
|
+
assertValidFunctionalLowering(functional, 'BeamNG functional package metadata');
|
|
728
|
+
if (functional.vehiclePackage?.schemaVersion !== 'helix.vehicle-package/2'
|
|
568
729
|
|| functional.addOnPackages?.schemaVersion !== 'helix.vehicle-addon-packages/1'
|
|
569
730
|
|| functional.build?.schemaVersion !== 'helix.vehicle-build-source/1'
|
|
570
731
|
|| functional.identity?.candidateId !== manifest.vehicle.candidateId
|
|
@@ -617,6 +778,7 @@ function packageVehicleSource(request) {
|
|
|
617
778
|
vehicle: candidateId } },
|
|
618
779
|
];
|
|
619
780
|
for (const [suffix, value, role] of [
|
|
781
|
+
['vehicle-lowering', functional, 'vehicle-functional-lowering'],
|
|
620
782
|
['vehicle-package', functional.vehiclePackage, 'vehicle-package'],
|
|
621
783
|
['vehicle-addons', functional.addOnPackages, 'vehicle-add-on-packages'],
|
|
622
784
|
['vehicle-build', functional.build, 'vehicle-build-source'],
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Bounded evaluator for the portable subset of BeamNG value expressions.
|
|
2
|
+
|
|
3
|
+
BeamNG JBeam values commonly use ``$=`` expressions backed by configuration
|
|
4
|
+
variables. This module deliberately implements only the data-only subset the
|
|
5
|
+
Bridge can prove equivalent. It never calls Python ``eval`` and it refuses
|
|
6
|
+
unknown variables, functions, tokens, non-finite arithmetic, and excessive
|
|
7
|
+
input before returning a value.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
import math
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any, Mapping
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
MAX_EXPRESSION_CHARS = 4096
|
|
18
|
+
MAX_TOKENS = 512
|
|
19
|
+
MAX_PARSE_DEPTH = 64
|
|
20
|
+
MAX_EVALUATION_STEPS = 2048
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BeamNGExpressionUnsupported(ValueError):
|
|
24
|
+
"""The expression is outside the explicitly supported portable subset."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ExpressionResolution:
|
|
29
|
+
status: str
|
|
30
|
+
value: Any
|
|
31
|
+
variables: tuple[str, ...]
|
|
32
|
+
reason: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class _Token:
|
|
37
|
+
kind: str
|
|
38
|
+
value: str
|
|
39
|
+
offset: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_NUMBER = re.compile(r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?")
|
|
43
|
+
_VARIABLE = re.compile(r"\$[A-Za-z_][A-Za-z0-9_]*")
|
|
44
|
+
_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
45
|
+
_TWO_CHAR_OPERATORS = {"==", "~=", "!=", "<=", ">="}
|
|
46
|
+
_ONE_CHAR_OPERATORS = set("()+-*/%^,<>")
|
|
47
|
+
_KEYWORDS = {"and", "or", "not", "nil", "true", "false"}
|
|
48
|
+
_ALLOWED_CALLS = {"case"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _tokenize(expression: str) -> list[_Token]:
|
|
52
|
+
if not isinstance(expression, str) or not expression.startswith("$="):
|
|
53
|
+
raise BeamNGExpressionUnsupported("value is not a BeamNG $= expression")
|
|
54
|
+
source = expression[2:]
|
|
55
|
+
if not source.strip():
|
|
56
|
+
raise BeamNGExpressionUnsupported("expression is empty")
|
|
57
|
+
if len(source) > MAX_EXPRESSION_CHARS:
|
|
58
|
+
raise BeamNGExpressionUnsupported(
|
|
59
|
+
f"expression exceeds {MAX_EXPRESSION_CHARS} characters")
|
|
60
|
+
tokens: list[_Token] = []
|
|
61
|
+
offset = 0
|
|
62
|
+
while offset < len(source):
|
|
63
|
+
if source[offset].isspace():
|
|
64
|
+
offset += 1
|
|
65
|
+
continue
|
|
66
|
+
match = _NUMBER.match(source, offset)
|
|
67
|
+
if match:
|
|
68
|
+
tokens.append(_Token("number", match.group(0), offset))
|
|
69
|
+
offset = match.end()
|
|
70
|
+
else:
|
|
71
|
+
match = _VARIABLE.match(source, offset)
|
|
72
|
+
if match:
|
|
73
|
+
tokens.append(_Token("variable", match.group(0), offset))
|
|
74
|
+
offset = match.end()
|
|
75
|
+
else:
|
|
76
|
+
match = _IDENTIFIER.match(source, offset)
|
|
77
|
+
if match:
|
|
78
|
+
value = match.group(0)
|
|
79
|
+
tokens.append(_Token("keyword" if value in _KEYWORDS else "identifier",
|
|
80
|
+
value, offset))
|
|
81
|
+
offset = match.end()
|
|
82
|
+
else:
|
|
83
|
+
pair = source[offset:offset + 2]
|
|
84
|
+
if pair in _TWO_CHAR_OPERATORS:
|
|
85
|
+
tokens.append(_Token("operator", pair, offset))
|
|
86
|
+
offset += 2
|
|
87
|
+
elif source[offset] in _ONE_CHAR_OPERATORS:
|
|
88
|
+
tokens.append(_Token("operator", source[offset], offset))
|
|
89
|
+
offset += 1
|
|
90
|
+
else:
|
|
91
|
+
raise BeamNGExpressionUnsupported(
|
|
92
|
+
f"unsupported token at offset {offset}: {source[offset:offset + 12]!r}")
|
|
93
|
+
if len(tokens) > MAX_TOKENS:
|
|
94
|
+
raise BeamNGExpressionUnsupported(f"expression exceeds {MAX_TOKENS} tokens")
|
|
95
|
+
tokens.append(_Token("eof", "", len(source)))
|
|
96
|
+
return tokens
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _Parser:
|
|
100
|
+
def __init__(self, tokens: list[_Token]):
|
|
101
|
+
self.tokens = tokens
|
|
102
|
+
self.index = 0
|
|
103
|
+
self.depth = 0
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def current(self) -> _Token:
|
|
107
|
+
return self.tokens[self.index]
|
|
108
|
+
|
|
109
|
+
def accept(self, value: str) -> bool:
|
|
110
|
+
if self.current.value == value:
|
|
111
|
+
self.index += 1
|
|
112
|
+
return True
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
def expect(self, value: str) -> None:
|
|
116
|
+
if not self.accept(value):
|
|
117
|
+
raise BeamNGExpressionUnsupported(
|
|
118
|
+
f"expected {value!r} at offset {self.current.offset}")
|
|
119
|
+
|
|
120
|
+
def parse(self) -> tuple[Any, ...]:
|
|
121
|
+
node = self.parse_or()
|
|
122
|
+
if self.current.kind != "eof":
|
|
123
|
+
raise BeamNGExpressionUnsupported(
|
|
124
|
+
f"unexpected token {self.current.value!r} at offset {self.current.offset}")
|
|
125
|
+
return node
|
|
126
|
+
|
|
127
|
+
def descend(self) -> None:
|
|
128
|
+
self.depth += 1
|
|
129
|
+
if self.depth > MAX_PARSE_DEPTH:
|
|
130
|
+
raise BeamNGExpressionUnsupported(
|
|
131
|
+
f"expression exceeds parse depth {MAX_PARSE_DEPTH}")
|
|
132
|
+
|
|
133
|
+
def ascend(self) -> None:
|
|
134
|
+
self.depth -= 1
|
|
135
|
+
|
|
136
|
+
def parse_or(self) -> tuple[Any, ...]:
|
|
137
|
+
node = self.parse_and()
|
|
138
|
+
while self.accept("or"):
|
|
139
|
+
node = ("binary", "or", node, self.parse_and())
|
|
140
|
+
return node
|
|
141
|
+
|
|
142
|
+
def parse_and(self) -> tuple[Any, ...]:
|
|
143
|
+
node = self.parse_compare()
|
|
144
|
+
while self.accept("and"):
|
|
145
|
+
node = ("binary", "and", node, self.parse_compare())
|
|
146
|
+
return node
|
|
147
|
+
|
|
148
|
+
def parse_compare(self) -> tuple[Any, ...]:
|
|
149
|
+
node = self.parse_add()
|
|
150
|
+
if self.current.value in ("==", "~=", "!=", "<", "<=", ">", ">="):
|
|
151
|
+
operator = self.current.value
|
|
152
|
+
self.index += 1
|
|
153
|
+
node = ("binary", operator, node, self.parse_add())
|
|
154
|
+
if self.current.value in ("==", "~=", "!=", "<", "<=", ">", ">="):
|
|
155
|
+
raise BeamNGExpressionUnsupported("chained comparisons are not supported")
|
|
156
|
+
return node
|
|
157
|
+
|
|
158
|
+
def parse_add(self) -> tuple[Any, ...]:
|
|
159
|
+
node = self.parse_multiply()
|
|
160
|
+
while self.current.value in ("+", "-"):
|
|
161
|
+
operator = self.current.value
|
|
162
|
+
self.index += 1
|
|
163
|
+
node = ("binary", operator, node, self.parse_multiply())
|
|
164
|
+
return node
|
|
165
|
+
|
|
166
|
+
def parse_multiply(self) -> tuple[Any, ...]:
|
|
167
|
+
node = self.parse_unary()
|
|
168
|
+
while self.current.value in ("*", "/", "%"):
|
|
169
|
+
operator = self.current.value
|
|
170
|
+
self.index += 1
|
|
171
|
+
node = ("binary", operator, node, self.parse_unary())
|
|
172
|
+
return node
|
|
173
|
+
|
|
174
|
+
def parse_power(self) -> tuple[Any, ...]:
|
|
175
|
+
node = self.parse_primary()
|
|
176
|
+
if self.accept("^"):
|
|
177
|
+
node = ("binary", "^", node, self.parse_unary())
|
|
178
|
+
return node
|
|
179
|
+
|
|
180
|
+
def parse_unary(self) -> tuple[Any, ...]:
|
|
181
|
+
if self.current.value in ("+", "-", "not"):
|
|
182
|
+
operator = self.current.value
|
|
183
|
+
self.index += 1
|
|
184
|
+
return ("unary", operator, self.parse_unary())
|
|
185
|
+
return self.parse_power()
|
|
186
|
+
|
|
187
|
+
def parse_primary(self) -> tuple[Any, ...]:
|
|
188
|
+
token = self.current
|
|
189
|
+
if token.kind == "number":
|
|
190
|
+
self.index += 1
|
|
191
|
+
value = float(token.value)
|
|
192
|
+
if not math.isfinite(value):
|
|
193
|
+
raise BeamNGExpressionUnsupported("numeric literal is not finite")
|
|
194
|
+
return ("literal", value)
|
|
195
|
+
if token.kind == "variable":
|
|
196
|
+
self.index += 1
|
|
197
|
+
return ("variable", token.value)
|
|
198
|
+
if token.kind == "keyword" and token.value in ("nil", "true", "false"):
|
|
199
|
+
self.index += 1
|
|
200
|
+
return ("literal", {"nil": None, "true": True, "false": False}[token.value])
|
|
201
|
+
if token.kind == "identifier":
|
|
202
|
+
self.index += 1
|
|
203
|
+
name = token.value
|
|
204
|
+
if not self.accept("("):
|
|
205
|
+
raise BeamNGExpressionUnsupported(f"unknown symbol {name!r}")
|
|
206
|
+
if name not in _ALLOWED_CALLS:
|
|
207
|
+
raise BeamNGExpressionUnsupported(f"unsupported call {name!r}")
|
|
208
|
+
self.descend()
|
|
209
|
+
try:
|
|
210
|
+
arguments = [self.parse_or()]
|
|
211
|
+
while self.accept(","):
|
|
212
|
+
arguments.append(self.parse_or())
|
|
213
|
+
self.expect(")")
|
|
214
|
+
finally:
|
|
215
|
+
self.ascend()
|
|
216
|
+
if name == "case" and len(arguments) != 3:
|
|
217
|
+
raise BeamNGExpressionUnsupported("case() requires exactly three arguments")
|
|
218
|
+
return ("call", name, tuple(arguments))
|
|
219
|
+
if self.accept("("):
|
|
220
|
+
self.descend()
|
|
221
|
+
try:
|
|
222
|
+
node = self.parse_or()
|
|
223
|
+
self.expect(")")
|
|
224
|
+
finally:
|
|
225
|
+
self.ascend()
|
|
226
|
+
return node
|
|
227
|
+
raise BeamNGExpressionUnsupported(
|
|
228
|
+
f"expected a value at offset {token.offset}, found {token.value!r}")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _variables(node: tuple[Any, ...], output: set[str]) -> None:
|
|
232
|
+
if node[0] == "variable":
|
|
233
|
+
output.add(node[1])
|
|
234
|
+
elif node[0] == "unary":
|
|
235
|
+
_variables(node[2], output)
|
|
236
|
+
elif node[0] == "binary":
|
|
237
|
+
_variables(node[2], output)
|
|
238
|
+
_variables(node[3], output)
|
|
239
|
+
elif node[0] == "call":
|
|
240
|
+
for argument in node[2]:
|
|
241
|
+
_variables(argument, output)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _truthy(value: Any) -> bool:
|
|
245
|
+
return value is not None and value is not False
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class _Evaluator:
|
|
249
|
+
def __init__(self, variables: Mapping[str, Any]):
|
|
250
|
+
self.variables = variables
|
|
251
|
+
self.steps = 0
|
|
252
|
+
|
|
253
|
+
def evaluate(self, node: tuple[Any, ...]) -> Any:
|
|
254
|
+
self.steps += 1
|
|
255
|
+
if self.steps > MAX_EVALUATION_STEPS:
|
|
256
|
+
raise BeamNGExpressionUnsupported(
|
|
257
|
+
f"expression exceeds {MAX_EVALUATION_STEPS} evaluation steps")
|
|
258
|
+
kind = node[0]
|
|
259
|
+
if kind == "literal":
|
|
260
|
+
return node[1]
|
|
261
|
+
if kind == "variable":
|
|
262
|
+
return self.variables[node[1]]
|
|
263
|
+
if kind == "unary":
|
|
264
|
+
operator, value = node[1], self.evaluate(node[2])
|
|
265
|
+
if operator == "not":
|
|
266
|
+
return not _truthy(value)
|
|
267
|
+
number = self.number(value, operator)
|
|
268
|
+
return number if operator == "+" else -number
|
|
269
|
+
if kind == "call":
|
|
270
|
+
condition = self.evaluate(node[2][0])
|
|
271
|
+
return self.evaluate(node[2][1] if _truthy(condition) else node[2][2])
|
|
272
|
+
operator = node[1]
|
|
273
|
+
left = self.evaluate(node[2])
|
|
274
|
+
if operator == "and":
|
|
275
|
+
return self.evaluate(node[3]) if _truthy(left) else left
|
|
276
|
+
if operator == "or":
|
|
277
|
+
return left if _truthy(left) else self.evaluate(node[3])
|
|
278
|
+
right = self.evaluate(node[3])
|
|
279
|
+
if operator in ("==", "~=", "!="):
|
|
280
|
+
equal = left == right
|
|
281
|
+
return equal if operator == "==" else not equal
|
|
282
|
+
if operator in ("<", "<=", ">", ">="):
|
|
283
|
+
first, second = self.number(left, operator), self.number(right, operator)
|
|
284
|
+
return {"<": first < second, "<=": first <= second,
|
|
285
|
+
">": first > second, ">=": first >= second}[operator]
|
|
286
|
+
first, second = self.number(left, operator), self.number(right, operator)
|
|
287
|
+
try:
|
|
288
|
+
if operator == "+":
|
|
289
|
+
result = first + second
|
|
290
|
+
elif operator == "-":
|
|
291
|
+
result = first - second
|
|
292
|
+
elif operator == "*":
|
|
293
|
+
result = first * second
|
|
294
|
+
elif operator == "/":
|
|
295
|
+
if second == 0:
|
|
296
|
+
raise BeamNGExpressionUnsupported("division by zero")
|
|
297
|
+
result = first / second
|
|
298
|
+
elif operator == "%":
|
|
299
|
+
if second == 0:
|
|
300
|
+
raise BeamNGExpressionUnsupported("modulo by zero")
|
|
301
|
+
result = first % second
|
|
302
|
+
elif operator == "^":
|
|
303
|
+
if abs(second) > 1000:
|
|
304
|
+
raise BeamNGExpressionUnsupported("exponent exceeds the portable bound")
|
|
305
|
+
# Keep the evaluator in the real-number domain. Python's
|
|
306
|
+
# ``**`` can yield complex values for fractional powers of a
|
|
307
|
+
# negative base, which are not portable BeamNG numerics.
|
|
308
|
+
result = math.pow(first, second)
|
|
309
|
+
else: # parser admits no other binary operator
|
|
310
|
+
raise BeamNGExpressionUnsupported(f"unsupported operator {operator!r}")
|
|
311
|
+
except (OverflowError, ValueError, ZeroDivisionError, TypeError) as error:
|
|
312
|
+
raise BeamNGExpressionUnsupported(f"invalid arithmetic: {error}") from error
|
|
313
|
+
if not math.isfinite(result):
|
|
314
|
+
raise BeamNGExpressionUnsupported("arithmetic result is not finite")
|
|
315
|
+
return result
|
|
316
|
+
|
|
317
|
+
@staticmethod
|
|
318
|
+
def number(value: Any, operator: str) -> float:
|
|
319
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
320
|
+
raise BeamNGExpressionUnsupported(
|
|
321
|
+
f"operator {operator!r} requires finite numeric operands")
|
|
322
|
+
return float(value)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def resolve_beamng_expression(expression: Any,
|
|
326
|
+
selected_variables: Mapping[str, Any]) -> ExpressionResolution:
|
|
327
|
+
"""Resolve one literal/expression without executing source code.
|
|
328
|
+
|
|
329
|
+
Numeric literals are returned as ``literal``. Direct ``$variable``
|
|
330
|
+
references and ``$=`` expressions become ``derived`` only when every
|
|
331
|
+
referenced variable is present in the supplied selected configuration
|
|
332
|
+
environment and evaluation stays inside the allowlist. All other values
|
|
333
|
+
return ``unsupported`` with a stable reason.
|
|
334
|
+
"""
|
|
335
|
+
if isinstance(expression, (int, float)) and not isinstance(expression, bool):
|
|
336
|
+
if math.isfinite(expression):
|
|
337
|
+
return ExpressionResolution("literal", float(expression), ())
|
|
338
|
+
return ExpressionResolution("unsupported", None, (), "numeric literal is not finite")
|
|
339
|
+
# BeamNG also permits a value to be a direct selected-variable reference,
|
|
340
|
+
# without the ``$=`` expression marker. Route it through the exact same
|
|
341
|
+
# parser/evaluator boundary so missing, non-scalar, and non-finite values
|
|
342
|
+
# fail closed instead of falling back to a JBeam default.
|
|
343
|
+
if isinstance(expression, str) and _VARIABLE.fullmatch(expression):
|
|
344
|
+
expression = f"$={expression}"
|
|
345
|
+
used: set[str] = set()
|
|
346
|
+
try:
|
|
347
|
+
tokens = _tokenize(expression)
|
|
348
|
+
tree = _Parser(tokens).parse()
|
|
349
|
+
_variables(tree, used)
|
|
350
|
+
unknown = sorted(name for name in used if name not in selected_variables)
|
|
351
|
+
if unknown:
|
|
352
|
+
raise BeamNGExpressionUnsupported(
|
|
353
|
+
f"unknown selected variable(s): {', '.join(unknown)}")
|
|
354
|
+
non_scalar = sorted(name for name in used
|
|
355
|
+
if selected_variables[name] is not None
|
|
356
|
+
and not isinstance(selected_variables[name], (int, float, bool)))
|
|
357
|
+
if non_scalar:
|
|
358
|
+
raise BeamNGExpressionUnsupported(
|
|
359
|
+
f"non-scalar selected variable(s): {', '.join(non_scalar)}")
|
|
360
|
+
non_finite = sorted(name for name in used
|
|
361
|
+
if isinstance(selected_variables[name], float)
|
|
362
|
+
and not math.isfinite(selected_variables[name]))
|
|
363
|
+
if non_finite:
|
|
364
|
+
raise BeamNGExpressionUnsupported(
|
|
365
|
+
f"non-finite selected variable(s): {', '.join(non_finite)}")
|
|
366
|
+
value = _Evaluator(selected_variables).evaluate(tree)
|
|
367
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
368
|
+
value = float(value)
|
|
369
|
+
if not math.isfinite(value):
|
|
370
|
+
raise BeamNGExpressionUnsupported("expression result is not finite")
|
|
371
|
+
return ExpressionResolution("derived", value, tuple(sorted(used)))
|
|
372
|
+
except BeamNGExpressionUnsupported as error:
|
|
373
|
+
return ExpressionResolution("unsupported", None, tuple(sorted(used)), str(error))
|
|
@@ -22,8 +22,9 @@ from beamng_jbeam import (JBeamDocumentCache, MAX_SELECTED_PART_INDEX_BYTES,
|
|
|
22
22
|
SELECTED_PART_INDEX_SCHEMA, build_part_catalog,
|
|
23
23
|
bounded_asset_paths, decode_jbeam_table,
|
|
24
24
|
resolve_part_closure)
|
|
25
|
+
from beamng_expression import ExpressionResolution, resolve_beamng_expression
|
|
25
26
|
|
|
26
|
-
SCHEMA = "helix.beamng-functional-lowering/
|
|
27
|
+
SCHEMA = "helix.beamng-functional-lowering/2"
|
|
27
28
|
SOURCE_SCHEMA = "helix.beamng-vehicle-source/1"
|
|
28
29
|
AUDIO_SLOTS = [
|
|
29
30
|
"engineLadder", "exhaustLadder", "idle", "startup", "shutdown", "shiftUp", "shiftDown", "clutch",
|
|
@@ -527,6 +528,515 @@ def sourced(value: Any, unit: str, source: str | None = None, note: str | None =
|
|
|
527
528
|
return row
|
|
528
529
|
|
|
529
530
|
|
|
531
|
+
BRAKE_AXLE_PATTERN = re.compile(r"(?:^|_)brakes?_?([FR])(?:_|$)", re.I)
|
|
532
|
+
SUSPENSION_AXLE_PATTERN = re.compile(r"(?:^|_)(?:strut|coilover)_?([FR])(?:_|$)", re.I)
|
|
533
|
+
SWAYBAR_AXLE_PATTERN = re.compile(r"(?:^|_)(?:rally_)?swaybar_?([FR])(?:_|$)", re.I)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def selected_variable_environment(selected: Iterable[tuple[str, str, dict[str, Any], Path]],
|
|
537
|
+
config: dict[str, Any], config_path: Path,
|
|
538
|
+
label_source: Callable[[Path], str]) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
|
|
539
|
+
"""Resolve the selected JBeam defaults, then exact `.pc` variable overrides."""
|
|
540
|
+
values: dict[str, Any] = {}
|
|
541
|
+
receipts: dict[str, dict[str, Any]] = {}
|
|
542
|
+
for _, part_name, body, path in selected:
|
|
543
|
+
for row in decode_jbeam_table(body.get("variables")):
|
|
544
|
+
name, default = row.get("name"), row.get("default")
|
|
545
|
+
if not isinstance(name, str) or not re.fullmatch(r"\$[A-Za-z_][A-Za-z0-9_]*", name):
|
|
546
|
+
continue
|
|
547
|
+
if isinstance(default, float) and not math.isfinite(default):
|
|
548
|
+
continue
|
|
549
|
+
values[name] = default
|
|
550
|
+
receipts[name] = {"value": default, "selection": "selected-default",
|
|
551
|
+
"source": f"{label_source(path)}#{part_name}.variables.{name}.default"}
|
|
552
|
+
overrides = config.get("vars")
|
|
553
|
+
if isinstance(overrides, dict):
|
|
554
|
+
for name in sorted(overrides):
|
|
555
|
+
value = overrides[name]
|
|
556
|
+
if not isinstance(name, str) or not re.fullmatch(r"\$[A-Za-z_][A-Za-z0-9_]*", name):
|
|
557
|
+
continue
|
|
558
|
+
# Keep unsupported scalar/object overrides in the environment so
|
|
559
|
+
# they fail the exact expression rather than silently falling back
|
|
560
|
+
# to the JBeam default they override.
|
|
561
|
+
values[name] = value
|
|
562
|
+
receipt = {"value": value, "selection": "configuration-override",
|
|
563
|
+
"source": f"{config_path.name}#vars.{name}"}
|
|
564
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
565
|
+
# JSON has no portable non-finite number representation. Keep
|
|
566
|
+
# the evaluator's exact value, but serialize an explicit safe
|
|
567
|
+
# receipt instead of emitting invalid terminal JSON.
|
|
568
|
+
receipt.update({"value": None, "raw": repr(value), "status": "unsupported"})
|
|
569
|
+
receipts[name] = receipt
|
|
570
|
+
return values, receipts
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def pressure_wheel_options(body: dict[str, Any]) -> dict[str, Any]:
|
|
574
|
+
"""Merge only section-level option dictionaries, never anonymous wheel rows."""
|
|
575
|
+
section = body.get("pressureWheels")
|
|
576
|
+
if not isinstance(section, list):
|
|
577
|
+
return {}
|
|
578
|
+
options: dict[str, Any] = {}
|
|
579
|
+
for row in section[1:]:
|
|
580
|
+
if isinstance(row, dict):
|
|
581
|
+
options.update(row)
|
|
582
|
+
return options
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def selected_part_axle(pattern: re.Pattern[str], slot_name: str, part_name: str,
|
|
586
|
+
body: dict[str, Any]) -> str | None:
|
|
587
|
+
matches: set[str] = set()
|
|
588
|
+
for label in (slot_name, body.get("slotType"), part_name):
|
|
589
|
+
if not isinstance(label, str):
|
|
590
|
+
continue
|
|
591
|
+
match = pattern.search(label)
|
|
592
|
+
if match:
|
|
593
|
+
matches.add("front" if match.group(1).lower() == "f" else "rear")
|
|
594
|
+
return next(iter(matches)) if len(matches) == 1 else None
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def numeric_resolution(value: Any, variables: dict[str, Any]) -> ExpressionResolution:
|
|
598
|
+
result = resolve_beamng_expression(value, variables)
|
|
599
|
+
if result.status != "unsupported" and (isinstance(result.value, bool)
|
|
600
|
+
or not isinstance(result.value, (int, float))
|
|
601
|
+
or not math.isfinite(result.value)):
|
|
602
|
+
return ExpressionResolution("unsupported", None, result.variables,
|
|
603
|
+
"expression did not resolve to a finite number")
|
|
604
|
+
if result.status != "unsupported":
|
|
605
|
+
return ExpressionResolution(result.status, round(float(result.value), 12),
|
|
606
|
+
result.variables, result.reason)
|
|
607
|
+
return result
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def lower_selected_brakes(selected: list[tuple[str, str, dict[str, Any], Path]],
|
|
611
|
+
variables: dict[str, Any], variable_receipts: dict[str, dict[str, Any]],
|
|
612
|
+
label_source: Callable[[Path], str], citation_id: str,
|
|
613
|
+
provenance: list[dict[str, Any]]) -> tuple[dict[str, Any], dict[str, Any], bool]:
|
|
614
|
+
"""Lower the two selected brake-part option blocks into HELIX totals.
|
|
615
|
+
|
|
616
|
+
BeamNG's torque values apply to each wheel in the part-owned axle section;
|
|
617
|
+
HELIX's values are totals across the car. Front bias is therefore derived
|
|
618
|
+
from the per-wheel front/rear values before both axles are doubled.
|
|
619
|
+
"""
|
|
620
|
+
candidates: dict[str, list[dict[str, Any]]] = {"front": [], "rear": []}
|
|
621
|
+
unowned: list[dict[str, Any]] = []
|
|
622
|
+
for slot_name, part_name, body, path in selected:
|
|
623
|
+
options = pressure_wheel_options(body)
|
|
624
|
+
if "brakeTorque" not in options and "parkingTorque" not in options:
|
|
625
|
+
continue
|
|
626
|
+
axle = selected_part_axle(BRAKE_AXLE_PATTERN, slot_name, part_name, body)
|
|
627
|
+
receipt = {"slotType": slot_name, "partName": part_name,
|
|
628
|
+
"source": f"{label_source(path)}#{part_name}.pressureWheels",
|
|
629
|
+
"options": {key: options[key] for key in ("brakeTorque", "brakeInputSplit",
|
|
630
|
+
"brakeSplitCoef", "parkingTorque")
|
|
631
|
+
if key in options}}
|
|
632
|
+
if axle is None:
|
|
633
|
+
# Suspension defaults deliberately clear inherited brake options;
|
|
634
|
+
# they do not own an axle brake Package and cannot override the
|
|
635
|
+
# exact selected brake part.
|
|
636
|
+
if "brake" in slot_name.lower() or "brake" in part_name.lower():
|
|
637
|
+
unowned.append(receipt)
|
|
638
|
+
continue
|
|
639
|
+
candidates[axle].append(receipt)
|
|
640
|
+
|
|
641
|
+
axles: dict[str, dict[str, Any]] = {}
|
|
642
|
+
complete = True
|
|
643
|
+
for axle in ("front", "rear"):
|
|
644
|
+
rows = candidates[axle]
|
|
645
|
+
if len(rows) != 1:
|
|
646
|
+
complete = False
|
|
647
|
+
detail = f"Expected one selected {axle} brake part; found {len(rows)}."
|
|
648
|
+
provenance.append({"target": f"physics.brakes.{axle}",
|
|
649
|
+
"source": "selected JBeam pressureWheels",
|
|
650
|
+
"disposition": "unsupported", "detail": detail})
|
|
651
|
+
axles[axle] = {"status": "unsupported", "reason": detail,
|
|
652
|
+
"candidates": rows}
|
|
653
|
+
continue
|
|
654
|
+
receipt = rows[0]
|
|
655
|
+
options = receipt["options"]
|
|
656
|
+
fields: dict[str, Any] = {}
|
|
657
|
+
for field in ("brakeTorque", "parkingTorque"):
|
|
658
|
+
result = numeric_resolution(options.get(field), variables)
|
|
659
|
+
disposition = "source-literal" if result.status == "literal" else result.status
|
|
660
|
+
row: dict[str, Any] = {"raw": options.get(field), "status": result.status,
|
|
661
|
+
"variables": list(result.variables)}
|
|
662
|
+
if result.status != "unsupported":
|
|
663
|
+
row["value"] = result.value
|
|
664
|
+
else:
|
|
665
|
+
row["reason"] = result.reason
|
|
666
|
+
complete = False
|
|
667
|
+
fields[field] = row
|
|
668
|
+
provenance.append({"target": f"sourceSemantics.brakes.axles.{axle}.{field}",
|
|
669
|
+
"source": receipt["source"], "disposition": disposition,
|
|
670
|
+
"detail": result.reason or
|
|
671
|
+
(f"Selected literal {result.value:g}." if result.status == "literal" else
|
|
672
|
+
f"Resolved {options.get(field)!r} to {result.value:g} from selected variables.")})
|
|
673
|
+
for field in ("brakeInputSplit", "brakeSplitCoef"):
|
|
674
|
+
if field in options:
|
|
675
|
+
fields[field] = {"raw": options[field], "status": "preserved-source"}
|
|
676
|
+
axle_row = {key: value for key, value in receipt.items() if key != "options"}
|
|
677
|
+
axle_row.update(fields)
|
|
678
|
+
axles[axle] = axle_row
|
|
679
|
+
|
|
680
|
+
front_torque = axles.get("front", {}).get("brakeTorque", {}).get("value")
|
|
681
|
+
rear_torque = axles.get("rear", {}).get("brakeTorque", {}).get("value")
|
|
682
|
+
front_parking = axles.get("front", {}).get("parkingTorque", {}).get("value")
|
|
683
|
+
rear_parking = axles.get("rear", {}).get("parkingTorque", {}).get("value")
|
|
684
|
+
complete = complete and all(isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
685
|
+
for value in (front_torque, rear_torque, front_parking, rear_parking))
|
|
686
|
+
if complete:
|
|
687
|
+
complete = bool(front_torque > 0 and rear_torque > 0
|
|
688
|
+
and front_parking >= 0 and rear_parking >= 0
|
|
689
|
+
and front_parking + rear_parking > 0)
|
|
690
|
+
if not complete:
|
|
691
|
+
provenance.append({"target": "physics.brakes", "source": "selected JBeam pressureWheels",
|
|
692
|
+
"disposition": "unsupported",
|
|
693
|
+
"detail": "Selected brake torque must be positive on both axles and parking torque must be non-negative and present on at least one axle."})
|
|
694
|
+
|
|
695
|
+
if complete:
|
|
696
|
+
per_wheel_total = float(front_torque + rear_torque)
|
|
697
|
+
max_torque = round(per_wheel_total * 2, 6)
|
|
698
|
+
bias_front = round(float(front_torque) / per_wheel_total, 8)
|
|
699
|
+
parking_by_axle = {"front": float(front_parking), "rear": float(rear_parking)}
|
|
700
|
+
active_parking = [axle for axle, torque in parking_by_axle.items() if torque > 0]
|
|
701
|
+
handbrake_axle = active_parking[0] if len(active_parking) == 1 else "all"
|
|
702
|
+
handbrake_torque = round(sum(parking_by_axle.values()) * 2, 6)
|
|
703
|
+
brakes = {
|
|
704
|
+
"maxTorque": sourced(max_torque, "N.m", citation_id,
|
|
705
|
+
"Twice the sum of selected per-wheel front and rear brake torque."),
|
|
706
|
+
"biasFront": sourced(bias_front, "ratio", citation_id,
|
|
707
|
+
"Selected front per-wheel torque divided by front plus rear per-wheel torque."),
|
|
708
|
+
"handbrakeTorque": sourced(handbrake_torque, "N.m", citation_id,
|
|
709
|
+
"Twice the selected per-wheel parking torque across its active axle(s)."),
|
|
710
|
+
"handbrakeAxle": handbrake_axle,
|
|
711
|
+
}
|
|
712
|
+
for target, detail in (
|
|
713
|
+
("physics.brakes.maxTorque", f"2 * ({front_torque:g} + {rear_torque:g}) = {max_torque:g} N.m."),
|
|
714
|
+
("physics.brakes.biasFront", f"{front_torque:g} / ({front_torque:g} + {rear_torque:g}) = {bias_front:g}."),
|
|
715
|
+
("physics.brakes.handbrakeTorque", f"2 * ({front_parking:g} + {rear_parking:g}) = {handbrake_torque:g} N.m on {handbrake_axle}."),
|
|
716
|
+
):
|
|
717
|
+
provenance.append({"target": target, "source": "selected JBeam brake-part pressureWheels rows",
|
|
718
|
+
"disposition": "derived", "detail": detail})
|
|
719
|
+
else:
|
|
720
|
+
brakes = {
|
|
721
|
+
"maxTorque": sourced(3900, "N.m", note="HELIX functional fallback; selected brake rows were incomplete or unsupported."),
|
|
722
|
+
"biasFront": sourced(.6923, "ratio", note="HELIX functional fallback; selected axle torque was incomplete or unsupported."),
|
|
723
|
+
"handbrakeTorque": sourced(1800, "N.m", note="HELIX functional fallback; selected parking torque was incomplete or unsupported."),
|
|
724
|
+
"handbrakeAxle": "rear",
|
|
725
|
+
}
|
|
726
|
+
provenance.append({"target": "physics.brakes", "source": "HELIX functional fallback",
|
|
727
|
+
"disposition": "defaulted",
|
|
728
|
+
"detail": "Selected front/rear brake and parking torque rows did not all resolve; brake domain remains non-functional."})
|
|
729
|
+
|
|
730
|
+
used_variables = sorted({name for row in axles.values() if isinstance(row, dict)
|
|
731
|
+
for field in ("brakeTorque", "parkingTorque")
|
|
732
|
+
for name in row.get(field, {}).get("variables", [])})
|
|
733
|
+
semantics = {"variables": {name: variable_receipts[name] for name in used_variables
|
|
734
|
+
if name in variable_receipts},
|
|
735
|
+
"axles": axles, "unownedPressureWheelOptions": sorted(
|
|
736
|
+
unowned, key=lambda row: (row["slotType"], row["partName"], row["source"]))}
|
|
737
|
+
return brakes, semantics, complete
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def suspension_row_side(row: dict[str, Any]) -> str | None:
|
|
741
|
+
labels = [row.get("name"), row.get("id1"), row.get("id2")]
|
|
742
|
+
sides: set[str] = set()
|
|
743
|
+
for label in labels:
|
|
744
|
+
if not isinstance(label, str):
|
|
745
|
+
continue
|
|
746
|
+
match = re.search(r"(?:_|[0-9])([LR])$", label, re.I)
|
|
747
|
+
if match:
|
|
748
|
+
sides.add("left" if match.group(1).lower() == "l" else "right")
|
|
749
|
+
return next(iter(sides)) if len(sides) == 1 else None
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def resolution_receipt(raw: Any, variables: dict[str, Any], unit: str,
|
|
753
|
+
source: str, provenance: list[dict[str, Any]], target: str) -> dict[str, Any]:
|
|
754
|
+
result = numeric_resolution(raw, variables)
|
|
755
|
+
row: dict[str, Any] = {"raw": raw, "status": result.status, "unit": unit,
|
|
756
|
+
"variables": list(result.variables)}
|
|
757
|
+
disposition = "source-literal" if result.status == "literal" else result.status
|
|
758
|
+
if result.status == "unsupported":
|
|
759
|
+
row["reason"] = result.reason
|
|
760
|
+
detail = result.reason or "Selected value did not resolve."
|
|
761
|
+
else:
|
|
762
|
+
row["value"] = result.value
|
|
763
|
+
detail = (f"Selected literal {result.value:g} {unit}." if result.status == "literal" else
|
|
764
|
+
f"Resolved {raw!r} to {result.value:g} {unit} from selected variables.")
|
|
765
|
+
provenance.append({"target": target, "source": source,
|
|
766
|
+
"disposition": disposition, "detail": detail})
|
|
767
|
+
return row
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def symmetric_suspension_field(rows: list[dict[str, Any]], field: str,
|
|
771
|
+
variables: dict[str, Any], unit: str, source: str,
|
|
772
|
+
provenance: list[dict[str, Any]], target: str,
|
|
773
|
+
positive: bool = False,
|
|
774
|
+
require_velocity_split: bool = False) -> dict[str, Any]:
|
|
775
|
+
"""Collapse only a matching left/right pair from one selected leaf part."""
|
|
776
|
+
by_side: dict[str, list[tuple[Any, ExpressionResolution, dict[str, Any]]]] = {
|
|
777
|
+
"left": [], "right": []}
|
|
778
|
+
unsupported: list[ExpressionResolution] = []
|
|
779
|
+
unsupported_variables: set[str] = set()
|
|
780
|
+
for row in rows:
|
|
781
|
+
if field not in row:
|
|
782
|
+
continue
|
|
783
|
+
raw = row[field]
|
|
784
|
+
result = numeric_resolution(raw, variables)
|
|
785
|
+
if result.status == "unsupported":
|
|
786
|
+
unsupported.append(result)
|
|
787
|
+
unsupported_variables.update(result.variables)
|
|
788
|
+
continue
|
|
789
|
+
if positive and not (result.value > 0):
|
|
790
|
+
continue
|
|
791
|
+
side = suspension_row_side(row)
|
|
792
|
+
if side:
|
|
793
|
+
by_side[side].append((raw, result, row))
|
|
794
|
+
reason = None
|
|
795
|
+
chosen: list[tuple[Any, ExpressionResolution, dict[str, Any]]] = []
|
|
796
|
+
if unsupported:
|
|
797
|
+
reason = unsupported[0].reason
|
|
798
|
+
else:
|
|
799
|
+
for side in ("left", "right"):
|
|
800
|
+
values = by_side[side]
|
|
801
|
+
if len(values) != 1:
|
|
802
|
+
reason = f"expected one selected {side} {field} row; found {len(values)}"
|
|
803
|
+
break
|
|
804
|
+
chosen.append(values[0])
|
|
805
|
+
if reason is None and chosen[0][1].value != chosen[1][1].value:
|
|
806
|
+
reason = f"selected left/right {field} values do not match"
|
|
807
|
+
velocity_split = None
|
|
808
|
+
if reason is None and require_velocity_split:
|
|
809
|
+
split_rows = []
|
|
810
|
+
for _, _, row in chosen:
|
|
811
|
+
if "beamDampVelocitySplit" not in row:
|
|
812
|
+
reason = f"selected {field} rows are missing beamDampVelocitySplit"
|
|
813
|
+
break
|
|
814
|
+
split_rows.append((row["beamDampVelocitySplit"],
|
|
815
|
+
numeric_resolution(row["beamDampVelocitySplit"], variables)))
|
|
816
|
+
if reason is None:
|
|
817
|
+
unsupported_split = next((result for _, result in split_rows
|
|
818
|
+
if result.status == "unsupported"), None)
|
|
819
|
+
if unsupported_split:
|
|
820
|
+
unsupported_variables.update(unsupported_split.variables)
|
|
821
|
+
reason = unsupported_split.reason
|
|
822
|
+
elif split_rows[0][1].value != split_rows[1][1].value:
|
|
823
|
+
reason = f"selected left/right {field} beamDampVelocitySplit values do not match"
|
|
824
|
+
elif not isinstance(split_rows[0][1].value, (int, float)):
|
|
825
|
+
reason = f"selected {field} beamDampVelocitySplit is not numeric"
|
|
826
|
+
elif split_rows[0][1].value < 0:
|
|
827
|
+
reason = f"selected {field} beamDampVelocitySplit must be non-negative"
|
|
828
|
+
else:
|
|
829
|
+
velocity_split = split_rows[0]
|
|
830
|
+
if reason is not None:
|
|
831
|
+
receipt = {"status": "unsupported", "unit": unit, "reason": reason,
|
|
832
|
+
"variables": sorted(unsupported_variables)}
|
|
833
|
+
provenance.append({"target": target, "source": source,
|
|
834
|
+
"disposition": "unsupported", "detail": reason})
|
|
835
|
+
return receipt
|
|
836
|
+
raw = chosen[0][0]
|
|
837
|
+
result = chosen[0][1]
|
|
838
|
+
receipt = {"raw": raw, "status": result.status, "value": result.value,
|
|
839
|
+
"unit": unit, "variables": list(result.variables), "symmetricRows": 2}
|
|
840
|
+
if velocity_split is not None:
|
|
841
|
+
split_raw, split_result = velocity_split
|
|
842
|
+
receipt["velocitySplit"] = {
|
|
843
|
+
"raw": split_raw, "status": split_result.status, "value": split_result.value,
|
|
844
|
+
"unit": "m/s", "variables": list(split_result.variables), "symmetricRows": 2,
|
|
845
|
+
}
|
|
846
|
+
receipt["variables"] = sorted(set(receipt["variables"]) | set(split_result.variables))
|
|
847
|
+
disposition = "source-literal" if result.status == "literal" else "derived"
|
|
848
|
+
provenance.append({"target": target, "source": source, "disposition": disposition,
|
|
849
|
+
"detail": (f"Matching selected left/right rows resolve to {result.value:g} {unit}.")})
|
|
850
|
+
return receipt
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def lower_selected_suspension(selected: list[tuple[str, str, dict[str, Any], Path]],
|
|
854
|
+
variables: dict[str, Any], variable_receipts: dict[str, dict[str, Any]],
|
|
855
|
+
label_source: Callable[[Path], str],
|
|
856
|
+
chassis_mass: float, weight_front: float,
|
|
857
|
+
provenance: list[dict[str, Any]]) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
858
|
+
"""Extract selected strut/coilover semantics without treating structure as suspension.
|
|
859
|
+
|
|
860
|
+
BeamNG authors spring and velocity-sensitive damper beams at the selected
|
|
861
|
+
strut. HELIX currently authors wheel rate plus one global slow damper
|
|
862
|
+
triplet. The exact source values and their beam-space derivations are
|
|
863
|
+
retained here, while the runtime projection remains fail-closed until a
|
|
864
|
+
motion ratio and axle-specific fast/slow damping contract are available.
|
|
865
|
+
"""
|
|
866
|
+
leaves: dict[str, list[tuple[str, str, dict[str, Any], Path]]] = {"front": [], "rear": []}
|
|
867
|
+
excluded: list[dict[str, Any]] = []
|
|
868
|
+
anti_roll_candidates: dict[str, list[tuple[str, str, dict[str, Any], Path]]] = {
|
|
869
|
+
"front": [], "rear": []}
|
|
870
|
+
for slot_name, part_name, body, path in selected:
|
|
871
|
+
axle = selected_part_axle(SUSPENSION_AXLE_PATTERN, slot_name, part_name, body)
|
|
872
|
+
if axle:
|
|
873
|
+
leaves[axle].append((slot_name, part_name, body, path))
|
|
874
|
+
continue
|
|
875
|
+
sway_axle = selected_part_axle(SWAYBAR_AXLE_PATTERN, slot_name, part_name, body)
|
|
876
|
+
if sway_axle:
|
|
877
|
+
anti_roll_candidates[sway_axle].append((slot_name, part_name, body, path))
|
|
878
|
+
label = " ".join(str(value) for value in (slot_name, body.get("slotType"), part_name)).lower()
|
|
879
|
+
if not any(token in label for token in ("suspension", "strut", "swaybar")):
|
|
880
|
+
continue
|
|
881
|
+
structural_rows = [row for row in decode_jbeam_table(body.get("beams"))
|
|
882
|
+
if "beamSpring" in row or "beamDamp" in row]
|
|
883
|
+
if structural_rows:
|
|
884
|
+
reason = ("swaybar construction beams do not own anti-roll stiffness" if sway_axle else
|
|
885
|
+
"not selected through a strut/coilover leaf slot")
|
|
886
|
+
excluded.append({"slotType": slot_name, "partName": part_name,
|
|
887
|
+
"source": f"{label_source(path)}#{part_name}.beams",
|
|
888
|
+
"beamRows": len(structural_rows), "reason": reason})
|
|
889
|
+
|
|
890
|
+
axles: dict[str, dict[str, Any]] = {}
|
|
891
|
+
used_variables: set[str] = set()
|
|
892
|
+
extraction_complete = True
|
|
893
|
+
for axle in ("front", "rear"):
|
|
894
|
+
candidates = leaves[axle]
|
|
895
|
+
if len(candidates) != 1:
|
|
896
|
+
reason = f"Expected one selected {axle} strut/coilover leaf part; found {len(candidates)}."
|
|
897
|
+
provenance.append({"target": f"sourceSemantics.suspension.axles.{axle}",
|
|
898
|
+
"source": "selected JBeam part custody",
|
|
899
|
+
"disposition": "unsupported", "detail": reason})
|
|
900
|
+
axles[axle] = {"status": "unsupported", "reason": reason,
|
|
901
|
+
"candidates": [part for _, part, _, _ in candidates]}
|
|
902
|
+
extraction_complete = False
|
|
903
|
+
continue
|
|
904
|
+
slot_name, part_name, body, path = candidates[0]
|
|
905
|
+
source = f"{label_source(path)}#{part_name}.beams"
|
|
906
|
+
beams = decode_jbeam_table(body.get("beams"))
|
|
907
|
+
variable_springs = [row for row in beams if isinstance(row.get("beamSpring"), str)
|
|
908
|
+
and "precompressionRange" in row
|
|
909
|
+
and row["beamSpring"].startswith("$")]
|
|
910
|
+
literal_springs = [] if variable_springs else [
|
|
911
|
+
row for row in beams if isinstance(row.get("beamSpring"), (int, float))
|
|
912
|
+
and not isinstance(row.get("beamSpring"), bool)
|
|
913
|
+
and row["beamSpring"] > 0 and row.get("beamDamp") == 0
|
|
914
|
+
and "precompressionRange" in row]
|
|
915
|
+
spring_rows = variable_springs or literal_springs
|
|
916
|
+
spring = symmetric_suspension_field(
|
|
917
|
+
spring_rows, "beamSpring", variables, "N/m", source, provenance,
|
|
918
|
+
f"sourceSemantics.suspension.axles.{axle}.springRate", positive=True)
|
|
919
|
+
precompression = symmetric_suspension_field(
|
|
920
|
+
spring_rows, "precompressionRange", variables, "m", source, provenance,
|
|
921
|
+
f"sourceSemantics.suspension.axles.{axle}.precompressionRange")
|
|
922
|
+
damper_rows = [row for row in beams if row.get("beamSpring") == 0
|
|
923
|
+
and ("beamDampVelocitySplit" in row
|
|
924
|
+
or re.match(r"^(?:shock|damper)_", str(row.get("name", "")), re.I))]
|
|
925
|
+
damping = {}
|
|
926
|
+
for output_name, field in (("slowBump", "beamDamp"), ("fastBump", "beamDampFast"),
|
|
927
|
+
("slowRebound", "beamDampRebound"),
|
|
928
|
+
("fastRebound", "beamDampReboundFast")):
|
|
929
|
+
damping[output_name] = symmetric_suspension_field(
|
|
930
|
+
damper_rows, field, variables, "N.s/m", source, provenance,
|
|
931
|
+
f"sourceSemantics.suspension.axles.{axle}.damping.{output_name}",
|
|
932
|
+
positive=True, require_velocity_split=True)
|
|
933
|
+
travel_rows = [row for row in beams if row.get("beamSpring") == 0 and row.get("beamDamp") == 0
|
|
934
|
+
and not row.get("name") and "beamDampVelocitySplit" not in row
|
|
935
|
+
and isinstance(row.get("shortBoundRange"), (int, float))
|
|
936
|
+
and 0 < row["shortBoundRange"] <= .5]
|
|
937
|
+
bump_limit = symmetric_suspension_field(
|
|
938
|
+
travel_rows, "shortBoundRange", variables, "m", source, provenance,
|
|
939
|
+
f"sourceSemantics.suspension.axles.{axle}.bumpLimit", positive=True)
|
|
940
|
+
required = [spring, precompression, bump_limit, *damping.values()]
|
|
941
|
+
axle_complete = not any(row.get("status") == "unsupported" for row in required)
|
|
942
|
+
if not axle_complete:
|
|
943
|
+
extraction_complete = False
|
|
944
|
+
for row in required:
|
|
945
|
+
used_variables.update(row.get("variables", []))
|
|
946
|
+
derived: dict[str, Any] = {}
|
|
947
|
+
if spring.get("status") != "unsupported":
|
|
948
|
+
corner_mass = chassis_mass * (weight_front if axle == "front" else 1 - weight_front) / 2
|
|
949
|
+
critical = 2 * math.sqrt(float(spring["value"]) * corner_mass)
|
|
950
|
+
derived = {
|
|
951
|
+
"cornerMassKg": round(corner_mass, 6),
|
|
952
|
+
"beamNaturalHz": round(math.sqrt(float(spring["value"]) / corner_mass) / (2 * math.pi), 8),
|
|
953
|
+
"criticalDampingNsPerM": round(critical, 6),
|
|
954
|
+
}
|
|
955
|
+
for output_name, ratio_name in (("slowBump", "slowBumpRatio"),
|
|
956
|
+
("fastBump", "fastBumpRatio"),
|
|
957
|
+
("slowRebound", "slowReboundRatio"),
|
|
958
|
+
("fastRebound", "fastReboundRatio")):
|
|
959
|
+
value = damping[output_name].get("value")
|
|
960
|
+
if isinstance(value, (int, float)):
|
|
961
|
+
derived[ratio_name] = round(float(value) / critical, 8)
|
|
962
|
+
provenance.append({"target": f"sourceSemantics.suspension.axles.{axle}.derived",
|
|
963
|
+
"source": source, "disposition": "derived",
|
|
964
|
+
"detail": "Beam natural frequency and damping ratios derived from selected beam rates and the emitted HELIX corner mass; not claimed as wheel-space ride values without motion ratio."})
|
|
965
|
+
axles[axle] = {"status": "resolved" if axle_complete else "unsupported",
|
|
966
|
+
"slotType": slot_name, "partName": part_name, "source": source,
|
|
967
|
+
"springRate": spring, "precompressionRange": precompression,
|
|
968
|
+
"damping": damping, "bumpLimit": bump_limit, "derived": derived,
|
|
969
|
+
"rowCounts": {"spring": len(spring_rows), "damper": len(damper_rows),
|
|
970
|
+
"travelLimit": len(travel_rows)}}
|
|
971
|
+
|
|
972
|
+
anti_roll: dict[str, Any] = {}
|
|
973
|
+
for axle in ("front", "rear"):
|
|
974
|
+
candidates = anti_roll_candidates[axle]
|
|
975
|
+
if len(candidates) != 1:
|
|
976
|
+
anti_roll[axle] = {"status": "unsupported",
|
|
977
|
+
"reason": f"expected one selected {axle} swaybar part; found {len(candidates)}"}
|
|
978
|
+
extraction_complete = False
|
|
979
|
+
continue
|
|
980
|
+
slot_name, part_name, body, path = candidates[0]
|
|
981
|
+
source = f"{label_source(path)}#{part_name}.torsionbars"
|
|
982
|
+
torsion_rows = [row for row in decode_jbeam_table(body.get("torsionbars")) if "spring" in row]
|
|
983
|
+
if len(torsion_rows) == 1:
|
|
984
|
+
torsion = resolution_receipt(torsion_rows[0]["spring"], variables, "N.m/rad", source,
|
|
985
|
+
provenance, f"sourceSemantics.suspension.antiRoll.{axle}.torsionSpring")
|
|
986
|
+
used_variables.update(torsion.get("variables", []))
|
|
987
|
+
else:
|
|
988
|
+
reason = f"expected one selected torsionbar spring row; found {len(torsion_rows)}"
|
|
989
|
+
torsion = {"status": "unsupported", "unit": "N.m/rad", "reason": reason,
|
|
990
|
+
"variables": []}
|
|
991
|
+
provenance.append({"target": f"sourceSemantics.suspension.antiRoll.{axle}.torsionSpring",
|
|
992
|
+
"source": source, "disposition": "unsupported", "detail": reason})
|
|
993
|
+
if torsion.get("status") == "unsupported":
|
|
994
|
+
extraction_complete = False
|
|
995
|
+
anti_roll[axle] = {"slotType": slot_name, "partName": part_name, "source": source,
|
|
996
|
+
"torsionSpring": torsion, "runtimeStatus": "unsupported",
|
|
997
|
+
"reason": "BeamNG torsional stiffness requires selected lever-arm and wheel motion-ratio geometry before HELIX N/m conversion."}
|
|
998
|
+
|
|
999
|
+
runtime_blockers = [
|
|
1000
|
+
"Selected beam spring rate requires wheel motion ratio before it is a HELIX wheel-space ride frequency.",
|
|
1001
|
+
"HELIX PackageSuspension has one global damping triplet and no axle-specific fast/slow velocity split.",
|
|
1002
|
+
"BeamNG strut bounds and precompression require suspension motion geometry before HELIX static ride height/travel conversion.",
|
|
1003
|
+
"BeamNG torsionbar stiffness requires lever-arm and wheel motion-ratio geometry before HELIX anti-roll N/m conversion.",
|
|
1004
|
+
]
|
|
1005
|
+
for target, detail in (("rideHzFront", runtime_blockers[0]), ("rideHzRear", runtime_blockers[0]),
|
|
1006
|
+
("dampingRatio", runtime_blockers[1]), ("bumpScale", runtime_blockers[1]),
|
|
1007
|
+
("reboundScale", runtime_blockers[1]), ("staticRideHeight", runtime_blockers[2]),
|
|
1008
|
+
("bumpTravel", runtime_blockers[2]), ("antiRollBarFront", runtime_blockers[3]),
|
|
1009
|
+
("antiRollBarRear", runtime_blockers[3])):
|
|
1010
|
+
provenance.append({"target": f"physics.suspension.{target}",
|
|
1011
|
+
"source": "selected JBeam suspension semantics",
|
|
1012
|
+
"disposition": "unsupported", "detail": detail})
|
|
1013
|
+
suspension = {
|
|
1014
|
+
"rideHzFront": sourced(1.8, "Hz", note="HELIX sports prior; selected beam rate lacks wheel motion ratio."),
|
|
1015
|
+
"rideHzRear": sourced(1.8, "Hz", note="HELIX sports prior; selected beam rate lacks wheel motion ratio."),
|
|
1016
|
+
"dampingRatio": sourced(.45, "ratio", note="HELIX sports prior; selected axle fast/slow damping cannot fit the global runtime field exactly."),
|
|
1017
|
+
"bumpScale": sourced(1, "ratio", note="HELIX solver prior; selected axle damping is retained in source semantics."),
|
|
1018
|
+
"reboundScale": sourced(1, "ratio", note="HELIX solver prior; selected axle damping is retained in source semantics."),
|
|
1019
|
+
"staticRideHeight": sourced(.34, "m", note="Wheel-radius-derived prior; selected strut geometry does not yet provide wheel-space ride height."),
|
|
1020
|
+
"bumpTravel": sourced(.1, "m", note="HELIX sports prior; selected strut bound is retained in beam space."),
|
|
1021
|
+
"antiRollBarFront": sourced(0, "N/m", note="HELIX off fallback; selected torsionbar lacks exact wheel-space conversion."),
|
|
1022
|
+
"antiRollBarRear": sourced(0, "N/m", note="HELIX off fallback; selected torsionbar lacks exact wheel-space conversion."),
|
|
1023
|
+
}
|
|
1024
|
+
provenance.append({"target": "physics.suspension", "source": "HELIX functional fallback",
|
|
1025
|
+
"disposition": "defaulted",
|
|
1026
|
+
"detail": "Exact selected leaf semantics were retained, but runtime suspension remains non-functional until every wheel-space blocker is resolved."})
|
|
1027
|
+
counts = {"selectedLeafParts": sum(len(rows) for rows in leaves.values()),
|
|
1028
|
+
"excludedStructuralParts": len(excluded),
|
|
1029
|
+
"resolvedAxles": sum(row.get("status") != "unsupported" for row in axles.values()),
|
|
1030
|
+
"sourceRows": sum(sum(row.get("rowCounts", {}).values()) for row in axles.values())}
|
|
1031
|
+
semantics = {"variables": {name: variable_receipts[name] for name in sorted(used_variables)
|
|
1032
|
+
if name in variable_receipts},
|
|
1033
|
+
"axles": axles, "antiRoll": anti_roll,
|
|
1034
|
+
"excludedStructuralParts": sorted(excluded, key=lambda row: (row["slotType"], row["partName"])),
|
|
1035
|
+
"runtimeBlockers": runtime_blockers, "extractionComplete": extraction_complete,
|
|
1036
|
+
"counts": counts}
|
|
1037
|
+
return suspension, semantics
|
|
1038
|
+
|
|
1039
|
+
|
|
530
1040
|
def diff(kind: str, ratio: float, preload: float, lock: float, reverse: float, torque_split: float = .5,
|
|
531
1041
|
friction: float = 0, dynamic: float = 0, lock_torque: float = 0,
|
|
532
1042
|
viscous_coef: float = 0, viscous_torque: float = 0) -> dict[str, Any]:
|
|
@@ -573,6 +1083,8 @@ def main() -> None:
|
|
|
573
1083
|
config = json.loads(config_path.read_text())
|
|
574
1084
|
if config.get("format") != 2 or not isinstance(config.get("parts"), dict):
|
|
575
1085
|
fail("configuration must be BeamNG .pc format 2 with a parts object")
|
|
1086
|
+
if "vars" in config and not isinstance(config["vars"], dict):
|
|
1087
|
+
fail("configuration vars must be an object when present")
|
|
576
1088
|
config_model = config.get("model") or config.get("mainPartName") or candidate
|
|
577
1089
|
if config_model != candidate:
|
|
578
1090
|
fail("configuration model/mainPartName does not match vehicle-source candidate")
|
|
@@ -906,6 +1418,18 @@ def main() -> None:
|
|
|
906
1418
|
shift_delay, shift_key = number(value_index, ["transmissionShiftDelay", "shiftDelay"], .25)
|
|
907
1419
|
|
|
908
1420
|
source_ref = citation_id
|
|
1421
|
+
selected_variables, selected_variable_receipts = selected_variable_environment(
|
|
1422
|
+
selected, config, config_path, source_label)
|
|
1423
|
+
brakes, brake_semantics, brakes_functional = lower_selected_brakes(
|
|
1424
|
+
selected, selected_variables, selected_variable_receipts,
|
|
1425
|
+
source_label, citation_id, provenance)
|
|
1426
|
+
chassis_mass = (float(config_info["Weight"])
|
|
1427
|
+
if isinstance(config_info.get("Weight"), (int, float)) else
|
|
1428
|
+
source_mass if source_mass > 100 else 1365.0)
|
|
1429
|
+
weight_distribution_front = .58
|
|
1430
|
+
suspension, suspension_semantics = lower_selected_suspension(
|
|
1431
|
+
selected, selected_variables, selected_variable_receipts, source_label,
|
|
1432
|
+
chassis_mass, weight_distribution_front, provenance)
|
|
909
1433
|
turbo_parts = [(name, body, path) for _, name, body, path in selected if isinstance(body.get("turbocharger"), dict)]
|
|
910
1434
|
wastegate_psi = None
|
|
911
1435
|
for _, name, body, _ in selected:
|
|
@@ -1096,7 +1620,7 @@ def main() -> None:
|
|
|
1096
1620
|
"Selected configuration info curb mass." if isinstance(config_info.get("Weight"), (int, float)) else None)
|
|
1097
1621
|
if isinstance(config_info.get("Weight"), (int, float)) else sourced(source_mass, "kg", source_ref if source_mass > 100 else None,
|
|
1098
1622
|
"Sum of unique selected literal JBeam nodeWeight values; missing common selections remain visible in coverage." if source_mass > 100 else "Explicit fallback pending BeamNG node-mass accumulation."),
|
|
1099
|
-
"weightDistributionFront": sourced(
|
|
1623
|
+
"weightDistributionFront": sourced(weight_distribution_front, "ratio", note="Explicit sports-sedan prior."), "centreOfMassHeight": sourced(.52, "m", note="Explicit sports-sedan prior."),
|
|
1100
1624
|
"wheelbase": sourced(wheelbase, "m", source_ref if sockets else None, "Measured from Blender wheel sockets." if sockets else "Geometry socket derivation unavailable in compact fixture."),
|
|
1101
1625
|
"trackFront": sourced(track_front, "m", source_ref if sockets else None, "Measured from Blender wheel sockets." if sockets else "Geometry socket derivation unavailable in compact fixture."),
|
|
1102
1626
|
"trackRear": sourced(track_rear, "m", source_ref if sockets else None, "Measured from Blender wheel sockets." if sockets else "Geometry socket derivation unavailable in compact fixture."), "length": sourced(length, "m", None if not args.geometry else source_ref),
|
|
@@ -1117,18 +1641,13 @@ def main() -> None:
|
|
|
1117
1641
|
"finalDrive": sourced(final_drive, "ratio", source_ref if fd_key != "HELIX-default" else None), "shiftTime": sourced(shift_delay, "s", source_ref if shift_key != "HELIX-default" else None),
|
|
1118
1642
|
"autoUpRpm": sourced(max(idle + 1, limiter - 200), "rpm", note="HELIX automatic shift prior; ignored when the selected transmission is manual."), "autoDownRpm": sourced(2800, "rpm", note="HELIX automatic shift prior; ignored when the selected transmission is manual.")},
|
|
1119
1643
|
"driveBiasFront": sourced(.35, "ratio", note="1 - selected centre differential rear torque split."),
|
|
1120
|
-
"brakes":
|
|
1121
|
-
"biasFront": sourced(.6923, "ratio", note="2700/(2700+1200) from selected BeamNG brake expressions."), "handbrakeTorque": sourced(1800, "N.m", note="HELIX fallback."), "handbrakeAxle": "rear"},
|
|
1644
|
+
"brakes": brakes,
|
|
1122
1645
|
"aero": {"dragCoefficient": sourced(.32, "ratio", note="Explicit sports-sedan prior."), "frontalArea": sourced(2.2, "m2", note="width*height*shape factor prior."),
|
|
1123
1646
|
"airDensity": sourced(1.225, "kg/m3", note="ISA sea-level standard."), "downforceCoefficient": sourced(0, "ratio", note="No selected literal aerodynamic coefficient.")},
|
|
1124
1647
|
"grip": {"peak": sourced(1.1, "ratio", note="HELIX tyre-model prior."), "sliding": sourced(.85, "ratio", note="HELIX tyre-model prior."),
|
|
1125
1648
|
"lateralMultiplier": sourced(1, "ratio", note="HELIX tyre-model prior."), "biasFront": sourced(.5, "ratio", note="Neutral prior."),
|
|
1126
1649
|
"loadSensitivityExponent": sourced(.85, "ratio", note="HELIX tyre-model prior.")},
|
|
1127
|
-
"suspension":
|
|
1128
|
-
"rideHzRear": sourced(1.8, "Hz", note="Selected spring literals require corner mass to convert; sports prior used."), "dampingRatio": sourced(.45, "ratio", note="HELIX sports prior."),
|
|
1129
|
-
"bumpScale": sourced(1, "ratio", note="HELIX solver prior."), "reboundScale": sourced(1, "ratio", note="HELIX solver prior."),
|
|
1130
|
-
"staticRideHeight": sourced(.34, "m", note="Wheel-radius-derived prior."), "bumpTravel": sourced(.1, "m", note="HELIX sports prior."),
|
|
1131
|
-
"antiRollBarFront": sourced(1, "ratio", note="Source torsion bars require model conversion."), "antiRollBarRear": sourced(1, "ratio", note="Source torsion bars require model conversion.")},
|
|
1650
|
+
"suspension": suspension,
|
|
1132
1651
|
"steering": {"roadWheelLockDeg": sourced(34, "deg", note="Steering-wheel 510 degree source value needs rack ratio; road-wheel prior retained."),
|
|
1133
1652
|
"rackTime": sourced(.8, "s", note="Derived from selected steering in/out rate."), "speedSensitivity": sourced([[0, 1], [50, .45]], "m/s,ratio", note="HELIX steering assist prior.")},
|
|
1134
1653
|
"linearDamping": sourced(.02, "ratio", note="HELIX solver stability prior."), "angularDamping": sourced(.08, "ratio", note="HELIX solver stability prior.")}
|
|
@@ -1216,10 +1735,12 @@ def main() -> None:
|
|
|
1216
1735
|
for _, name, body, _ in selected for section in ("cameraChase", "cameraExternal", "camerasInternal")
|
|
1217
1736
|
if isinstance(body.get(section), list)],
|
|
1218
1737
|
"brakes": {"selectedParts": sorted(name for name in selected_names if "brake" in name.lower()),
|
|
1219
|
-
"sourceExpressions": sorted({str(
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1738
|
+
"sourceExpressions": sorted({str(row.get("brakeTorque", {}).get("raw"))
|
|
1739
|
+
for row in brake_semantics["axles"].values()
|
|
1740
|
+
if isinstance(row, dict) and isinstance(row.get("brakeTorque"), dict)
|
|
1741
|
+
and row["brakeTorque"].get("raw") is not None}),
|
|
1742
|
+
**brake_semantics},
|
|
1743
|
+
"suspension": suspension_semantics,
|
|
1223
1744
|
"wheelsTyres": {"selectedParts": sorted(name for name in selected_names if any(token in name.lower() for token in ("wheel", "rim", "tire", "tyre"))),
|
|
1224
1745
|
"literalRadii": sorted({float(value) for value in indexed_values(value_index, "radius") if isinstance(value, (int, float))})},
|
|
1225
1746
|
"electrics": sorted({str(value) for value in indexed_values(value_index, "electricsName") + indexed_values(value_index, "$electric")}),
|
|
@@ -1241,8 +1762,6 @@ def main() -> None:
|
|
|
1241
1762
|
prov("runtime.controllers", "selected JBeam controller tables", "unsupported", f"{len(controllers)} controller types retained in capability ledger.")
|
|
1242
1763
|
prov("runtime.softbody", "selected JBeam nodes/beams/triangles", "out-of-scope", "Not emitted into runtime contract.")
|
|
1243
1764
|
prov("runtime.fluids", "selected JBeam energy/thermal sections", "out-of-scope", "Not emitted into runtime contract.")
|
|
1244
|
-
prov("physics.brakes.maxTorque", "selected JBeam brakeTorque expressions", "defaulted", "Expressions retained; functional HELIX fallback emitted.")
|
|
1245
|
-
prov("physics.suspension", "selected JBeam spring/damper rows", "defaulted", "Literal spring/damper rows are retained for later conversion; explicit HELIX priors emitted.")
|
|
1246
1765
|
prov("simulation.land.tyreByAxle", "selected BeamNG wheel/tyre identities", "defaulted", "Compact common corpus lacks literal tyre model rows.")
|
|
1247
1766
|
prov("mesh.visual.tiers", "HELIX engine fidelity contract", "defaulted", "Presentation-only policy; simulation and identity are invariant.")
|
|
1248
1767
|
|
|
@@ -1257,7 +1776,7 @@ def main() -> None:
|
|
|
1257
1776
|
"engine": bool(engine_parts and len(torque) >= 2 and (not turbo_declared or induction is not None)),
|
|
1258
1777
|
"gearbox": ratios_sourced and transmission_kind_sourced,
|
|
1259
1778
|
"differentials": bool(diffs),
|
|
1260
|
-
"brakes":
|
|
1779
|
+
"brakes": brakes_functional,
|
|
1261
1780
|
"suspension": False,
|
|
1262
1781
|
"wheelsTyres": any(value for value in indexed_values(value_index, "radius")
|
|
1263
1782
|
if isinstance(value, (int, float))),
|
|
@@ -1279,6 +1798,13 @@ def main() -> None:
|
|
|
1279
1798
|
"resolvedSelectionPercent": round(100 * resolved_count / max(1, len(explicit)), 2),
|
|
1280
1799
|
"resolvedClosurePercent": round(100 * len(selected) / max(1, len(selected) + len(missing)), 2),
|
|
1281
1800
|
"sourceLiteralPercent": round(100 * literal_count / max(1, len(provenance)), 2),
|
|
1801
|
+
"suspensionProvenance": {
|
|
1802
|
+
disposition: sum(row["disposition"] == disposition
|
|
1803
|
+
and (row["target"].startswith("physics.suspension")
|
|
1804
|
+
or row["target"].startswith("sourceSemantics.suspension"))
|
|
1805
|
+
for row in provenance)
|
|
1806
|
+
for disposition in ("source-literal", "derived", "defaulted", "unsupported")
|
|
1807
|
+
},
|
|
1282
1808
|
"functionalDomains": functional_domains,
|
|
1283
1809
|
"futureEligibility": {"rigidDetachment": {
|
|
1284
1810
|
"eligiblePanelIds": future_detachable_panels,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": "helix.bridge-plugin/1",
|
|
3
3
|
"id": "hypersonic.beamng-vehicle",
|
|
4
4
|
"name": "BeamNG Vehicle Importer",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.5.0",
|
|
6
6
|
"trust": "local-untrusted",
|
|
7
7
|
"description": "Assess, convert, texture, lower, and acceptance-test a BeamNG vehicle plus its exact configuration before HELIX packaging.",
|
|
8
8
|
"permissions": [
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"executable": "bin/beamng-vehicle.mjs",
|
|
50
50
|
"timeoutSeconds": 120
|
|
51
51
|
},
|
|
52
|
-
"description": "Package the canonical vehicle GLB with its immutable source manifest, Vehicle Package v2, add-on records, and exact-selection Build."
|
|
52
|
+
"description": "Package the canonical vehicle GLB with its immutable source manifest, versioned lowering diagnostics, Vehicle Package v2, add-on records, and exact-selection Build."
|
|
53
53
|
}
|
|
54
54
|
]
|
|
55
55
|
}
|
|
@@ -5,7 +5,7 @@ export const BEAMNG_VEHICLE_SOURCE_SCHEMA = 'helix.beamng-vehicle-source/1';
|
|
|
5
5
|
|
|
6
6
|
export function markVehicleSourceFunctionallyLowered(manifest, lowering) {
|
|
7
7
|
if (manifest?.schemaVersion !== BEAMNG_VEHICLE_SOURCE_SCHEMA
|
|
8
|
-
|| lowering?.schemaVersion !== 'helix.beamng-functional-lowering/
|
|
8
|
+
|| lowering?.schemaVersion !== 'helix.beamng-functional-lowering/2'
|
|
9
9
|
|| lowering?.identity?.candidateId !== manifest?.vehicle?.candidateId
|
|
10
10
|
|| lowering?.identity?.bridgePlanKey !== manifest?.identity?.bridgePlanKey) {
|
|
11
11
|
throw new Error('cannot attach mismatched BeamNG functional lowering to source manifest');
|