Skip to content

Commit 8ce3289

Browse files
authored
fix(wasm,scripts): three claims #3182 made that are not true, and the gate that would have caught one (#3191)
itself is correct and unchanged here; what did not land is the review, and three of its findings were claims the code does not support. 1. The changeset says `IfcDoorStyle` and `IfcWindowStyle` were affected and implies this change fixes them. It does not. Both carry `has_geometry: false` in `legacy_entities.rs`, and `styling/prepass.rs:177` plus its siblings in `gpu_meshes/prepass.rs`, `processing/shard_classes.rs` and `processing/processor/mod.rs` gate type-geometry candidates on a bare `IfcType::from_str(name).is_subtype_of(IfcTypeProduct)`, which is false for any keyword IFC4X3 dropped. Both are discarded before a job exists, so they never reach the corrected line. The changeset text ships as the changelog and #3186 has not published yet, so this is correctable in place. #3187 tracks the nine sites. 2. `batch.rs` justified the unvalidated `content.get(start..end)` with "`content[start..end]` is the record `decode_and_cache` just parsed". False on the cache-hit path: `decoder.rs:443` returns the cached `Arc` without reading `start`/`end`, so only the miss path bounds-checks them. Narrowed to the span the job carries, with the fail-soft behaviour stated. 3. A test docblock said the six #3172 entities were "deliberately absent" because #3172 "is still in review". #3172 merged; the sentence was true when written and false on merge. The gate that would have caught the first one, derived rather than transcribed: `check-legacy-entity-coverage.mjs` already extracts the legacy arm keys and the generated `from_str` names from source and never intersected them. An arm whose key `from_str` already resolves makes the `Unknown` short-circuit in `legacy_aware_ifc_type_from_record` skip the remap, reopening exactly the wasm-vs-native divergence #3179 was filed for. Now checked, with a mutation case proving it fires. Also from the review: a Rust test pinning the same invariant behaviourally (it calls `from_str` for real, where the gate reads source text); a contract test for `extract_entity_type_name` next to the function rather than only in its caller's tests; one shared `LEGACY_KEYS` so the two opposite-direction tests cannot drift; a dead `type_end <= type_start` guard removed (`paren_pos >= 1` always, the one reachable equality is the empty slice `is_empty` already rejects); and three dead or duplicated items in `test-wasm-contract.mjs`. Every new test mutation-verified in both directions.
1 parent 2612446 commit 8ce3289

8 files changed

Lines changed: 472 additions & 101 deletions

File tree

.changeset/wasm-legacy-keyword-labels.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
Label legacy IFC keywords with their resolved type in the browser, not `"Unknown"`.
66

7-
The native pipeline resolves a legacy keyword through `legacy_entities.rs` and labels the node with its real base type. The browser path did not: the jobs wire carries only `(id, start, end)`, so `batch.rs` rebuilt the type from `entity.ifc_type` — the decoder's bare `IfcType::from_str` — and every keyword IFC4X3 dropped arrived as `Unknown` with the Unknown default colour. `IfcProxy`, the eight `*StandardCase` variants, both `*ElementedCase`, `IfcDoorStyle`, `IfcWindowStyle`, `IfcEquipmentElement` and the three IFC4X3 strata leaves were all affected. Type-exact visibility rules and styling consumers skipped them, and nothing threw.
7+
The native pipeline resolves a legacy keyword through `legacy_entities.rs` and labels the node with its real base type. The browser path did not: the jobs wire carries only `(id, start, end)`, so `batch.rs` rebuilt the type from `entity.ifc_type` — the decoder's bare `IfcType::from_str` — and a legacy keyword that reached that path arrived as `Unknown` with the Unknown default colour. The 22 `legacy_entities.rs` arms that carry geometry are fixed here, among them `IfcProxy`, the eight `*StandardCase` variants, both `*ElementedCase`, `IfcEquipmentElement`, the three IFC4X3 strata leaves and the six #3172 added. Type-exact visibility rules and styling consumers skipped them, and nothing threw.
8+
9+
`IfcDoorStyle` and `IfcWindowStyle` are NOT fixed by this change. The pre-passes gate type-geometry candidates on a bare `IfcType::from_str(name).is_subtype_of(IfcTypeProduct)`, which is false for any keyword IFC4X3 dropped, so both are discarded before a geometry job exists and never reach the corrected line. That is the same defect one layer up; #3187 enumerates the sites.
810

911
It cannot be recovered from the decoded value: `IfcType::Unknown` stores a CRC32 hash, not the name. It is recomputed from the record instead, which the batch already holds — a short scan to the first `(`, paid only by entities the decoder could not name.
1012

11-
Fixing it surfaced a second defect. `extract_entity_type_name` did not trim, so `#71= IFCCOLUMN(` — legal STEP, and what buildingSMART's own `column-straight-rectangle-tessellation.ifc` writes on all 26 of its entity lines — yielded `" IFCCOLUMN"` with a leading space, matching no lookup. The function had no production caller, so its broken contract had never been exercised.
13+
Fixing it surfaced a second defect. `extract_entity_type_name` did not trim, so `#71= IFCCOLUMN(` — legal STEP, and what buildingSMART's own `column-straight-rectangle-tessellation.ifc` writes on all 26 of its entity lines — yielded `" IFCCOLUMN"` with a leading space, matching no lookup. The function had no production caller, so its broken contract had never been exercised. `extract_entity_type_name` is `pub` in `ifc-lite-core`, so that is a behaviour change on a published Rust surface: it now returns the trimmed name, and `None` rather than `Some(" ")` for a record with only whitespace between `=` and `(`.

rust/core/src/fast_parse.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -270,10 +270,10 @@ pub fn extract_entity_type_name(bytes: &[u8]) -> Option<&str> {
270270
let type_start = eq_pos + 1;
271271
let type_end = eq_pos + paren_pos;
272272

273-
if type_end <= type_start {
274-
return None;
275-
}
276-
273+
// No `type_end <= type_start` guard: `bytes[eq_pos]` is `=`, never `(`, so
274+
// `paren_pos >= 1` and `type_end >= type_start` always. The one reachable
275+
// equality is `#1=(`, which yields an empty slice that the `is_empty` below
276+
// rejects. `an_unreadable_record_changes_nothing` covers that input.
277277
let name = std::str::from_utf8(&bytes[type_start..type_end]).ok()?.trim();
278278
(!name.is_empty()).then_some(name)
279279
}

rust/core/src/fast_parse_tests.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,36 @@ fn test_should_use_fast_path() {
161161
assert!(!should_use_fast_path("IFCWALL"));
162162
assert!(!should_use_fast_path("IFCEXTRUDEDAREASOLID"));
163163
}
164+
165+
/// `extract_entity_type_name`'s own contract, next to the function.
166+
///
167+
/// Its only production caller is `legacy_aware_ifc_type_from_record`, and the
168+
/// cases below were exercised only from that caller's tests until now. If the
169+
/// caller is ever deleted or rerouted, the contract keeps its coverage here.
170+
///
171+
/// The spaced forms are the reason this matters: STEP permits whitespace around
172+
/// `=`, and buildingSMART's own `column-straight-rectangle-tessellation.ifc`
173+
/// writes `#71= IFCCOLUMN(` on all 26 of its entity lines. Untrimmed, that
174+
/// yielded `" IFCCOLUMN"` and matched no lookup.
175+
#[test]
176+
fn extract_entity_type_name_trims_and_rejects_empty() {
177+
for (record, expected) in [
178+
(&b"#12=IFCCOLUMN('g');"[..], Some("IFCCOLUMN")),
179+
(&b"#12= IFCCOLUMN('g');"[..], Some("IFCCOLUMN")),
180+
(&b"#12=\tIFCCOLUMN('g');"[..], Some("IFCCOLUMN")),
181+
// Nothing between `=` and `(`: an empty name is None, not Some("").
182+
(&b"#12=();"[..], None),
183+
(&b"#12= ( );"[..], None),
184+
// No `=` and no `(` are both None rather than a panic.
185+
(&b"IFCCOLUMN('g');"[..], None),
186+
(&b"#12=IFCCOLUMN"[..], None),
187+
(&b""[..], None),
188+
] {
189+
assert_eq!(
190+
extract_entity_type_name(record),
191+
expected,
192+
"{:?}",
193+
std::str::from_utf8(record)
194+
);
195+
}
196+
}

rust/core/src/schema_helpers_tests.rs

Lines changed: 78 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,43 @@ fn nth_attribute_reversed_boundary_is_false() {
317317
assert!(!nth_attribute_is_present(b"garbage)stuff(more", 0));
318318
}
319319

320-
/// The five IFC2X3 products #3172 added to `legacy_entities.rs`.
320+
/// Every key in `legacy_entities.rs`, in one place.
321+
///
322+
/// Two tests assert OPPOSITE directions about these names and a second copy
323+
/// of the list would let them drift apart:
324+
/// `every_legacy_arm_maps_onto_a_known_product` says each maps to a known base
325+
/// type; `every_legacy_key_is_unknown_to_the_generated_enum` says none of them
326+
/// is a name `IfcType::from_str` already knows.
327+
const LEGACY_KEYS: [&str; 26] = [
328+
"IFCPRESENTATIONSTYLEASSIGNMENT",
329+
"IFCBEAMSTANDARDCASE",
330+
"IFCCOLUMNSTANDARDCASE",
331+
"IFCMEMBERSTANDARDCASE",
332+
"IFCPLATESTANDARDCASE",
333+
"IFCSLABSTANDARDCASE",
334+
"IFCDOORSTANDARDCASE",
335+
"IFCWINDOWSTANDARDCASE",
336+
"IFCOPENINGSTANDARDCASE",
337+
"IFCSLABELEMENTEDCASE",
338+
"IFCWALLELEMENTEDCASE",
339+
"IFCDOORSTYLE",
340+
"IFCWINDOWSTYLE",
341+
"IFCPROXY",
342+
"IFCBUILDINGELEMENT",
343+
"IFCBUILDINGELEMENTTYPE",
344+
"IFCEQUIPMENTELEMENT",
345+
"IFCELECTRICDISTRIBUTIONPOINT",
346+
"IFCELECTRICALELEMENT",
347+
"IFCCHAMFEREDGEFEATURE",
348+
"IFCROUNDEDEDGEFEATURE",
349+
"IFCSTRUCTURALLINEARACTIONVARYING",
350+
"IFCSTRUCTURALPLANARACTIONVARYING",
351+
"IFCSOLIDSTRATUM",
352+
"IFCVOIDSTRATUM",
353+
"IFCWATERSTRATUM",
354+
];
355+
356+
/// The six IFC2X3 products #3172 added to `legacy_entities.rs`.
321357
///
322358
/// Each one is CONCRETE and carries both `ObjectPlacement` and
323359
/// `Representation` in `@ifc-lite/data`'s IFC2X3 table, and each one resolved
@@ -396,34 +432,7 @@ fn edge_features_are_subtraction_operands_not_openings() {
396432
/// dropped as having no arm at all, while looking handled in the table.
397433
#[test]
398434
fn every_legacy_arm_maps_onto_a_known_product() {
399-
for name in [
400-
"IFCPRESENTATIONSTYLEASSIGNMENT",
401-
"IFCBEAMSTANDARDCASE",
402-
"IFCCOLUMNSTANDARDCASE",
403-
"IFCMEMBERSTANDARDCASE",
404-
"IFCPLATESTANDARDCASE",
405-
"IFCSLABSTANDARDCASE",
406-
"IFCDOORSTANDARDCASE",
407-
"IFCWINDOWSTANDARDCASE",
408-
"IFCOPENINGSTANDARDCASE",
409-
"IFCSLABELEMENTEDCASE",
410-
"IFCWALLELEMENTEDCASE",
411-
"IFCDOORSTYLE",
412-
"IFCWINDOWSTYLE",
413-
"IFCPROXY",
414-
"IFCBUILDINGELEMENT",
415-
"IFCBUILDINGELEMENTTYPE",
416-
"IFCEQUIPMENTELEMENT",
417-
"IFCELECTRICDISTRIBUTIONPOINT",
418-
"IFCELECTRICALELEMENT",
419-
"IFCCHAMFEREDGEFEATURE",
420-
"IFCROUNDEDEDGEFEATURE",
421-
"IFCSTRUCTURALLINEARACTIONVARYING",
422-
"IFCSTRUCTURALPLANARACTIONVARYING",
423-
"IFCSOLIDSTRATUM",
424-
"IFCVOIDSTRATUM",
425-
"IFCWATERSTRATUM",
426-
] {
435+
for name in LEGACY_KEYS {
427436
let info = crate::legacy_entities::get_legacy_entity_info(name)
428437
.unwrap_or_else(|| panic!("{name} has no arm in legacy_entities.rs"));
429438
assert!(
@@ -447,11 +456,13 @@ fn every_legacy_arm_maps_onto_a_known_product() {
447456
/// `Unknown`, and `Unknown` stores a CRC32 hash rather than the name, so the
448457
/// keyword survives only in the record.
449458
///
450-
/// The keywords here are ones `legacy_entities.rs` carries on `main`. The six
451-
/// #3172 adds are deliberately absent: this branch is cut from `main` and must
452-
/// not depend on a table that is still in review, or it goes green here and red
453-
/// on merge in whichever order the two land. The first draft used
454-
/// `IFCELECTRICALELEMENT` and failed for exactly that reason.
459+
/// The keywords here all predate #3172, which has since landed and wrote six
460+
/// arms, one of them replacing a misspelling (the table went 21 -> 26). They
461+
/// are kept because what this test exercises is the WIRE-UP --
462+
/// that a record reaches `legacy_entities.rs` at all -- not the table's
463+
/// contents, which `legacy_ifc2x3_products_resolve_to_their_own_supertype`
464+
/// above covers directly. Choosing rows that do not move keeps the two tests
465+
/// from failing together for one cause.
455466
#[test]
456467
fn a_legacy_record_resolves_where_the_decoded_type_cannot() {
457468
// What the decoder produces for these, and what the browser was emitting.
@@ -508,3 +519,36 @@ fn an_unreadable_record_changes_nothing() {
508519
assert_eq!(legacy_aware_ifc_type_from_record(unknown, record), unknown);
509520
}
510521
}
522+
523+
/// The unstated invariant `legacy_aware_ifc_type_from_record` rests on.
524+
///
525+
/// That function short-circuits on `!matches!(decoded, IfcType::Unknown(_))`,
526+
/// which is only equivalent to `legacy_aware_ifc_type` while every key in the
527+
/// legacy table is a name the generated enum does NOT know. If a schema
528+
/// regeneration ever emits an arm for one of these keys -- and several are real
529+
/// IFC2x3/IFC4 entities, `IFCPRESENTATIONSTYLEASSIGNMENT` among them --
530+
/// `from_str` stops returning `Unknown`, the early return fires, and the wasm
531+
/// path silently returns the raw variant while the native path still remaps it.
532+
/// That is exactly the wasm-vs-native divergence #3179 was filed for, and it
533+
/// would come back in a form no other test here observes.
534+
///
535+
/// `every_legacy_arm_maps_onto_a_known_product` asserts the BASE TYPE is known.
536+
/// This asserts the KEY is not: the opposite direction, and the one the
537+
/// short-circuit depends on.
538+
///
539+
/// This is the BEHAVIOURAL half -- it calls `from_str` for real. The derived
540+
/// half is in `scripts/check-legacy-entity-coverage.mjs`, which reads both sets
541+
/// out of the source and so also catches a 27th arm added without touching
542+
/// `LEGACY_KEYS`, which this test cannot see.
543+
#[test]
544+
fn every_legacy_key_is_unknown_to_the_generated_enum() {
545+
for key in LEGACY_KEYS {
546+
assert!(
547+
matches!(IfcType::from_str(key), IfcType::Unknown(_)),
548+
"{key} is now known to `IfcType::from_str`, so the `Unknown` \
549+
short-circuit in `legacy_aware_ifc_type_from_record` skips its \
550+
legacy remap and the browser diverges from the native path again"
551+
);
552+
}
553+
}
554+

rust/wasm-bindings/src/api/gpu_meshes/batch.rs

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -400,23 +400,33 @@ impl IfcAPI {
400400
continue;
401401
};
402402
// LEGACY-AWARE, like the native pre-pass at
403-
// `processing/src/processor/mod.rs:711`. `entity.ifc_type` comes
404-
// from the decoder's bare `IfcType::from_str`, so every keyword
405-
// IFC4X3 dropped -- IfcProxy, the *StandardCase family, the strata
406-
// leaves, the six #3172 added -- arrived here as `Unknown` and was
407-
// emitted to the browser with `ifcType: "Unknown"` and the Unknown
408-
// default colour, while the CLI and exporters labelled the same
409-
// entity correctly (#3179).
403+
// `processing/src/processor/mod.rs:711`. Without this, a legacy
404+
// keyword reaching this path arrived as `ifcType: "Unknown"` with
405+
// the Unknown default colour, while the CLI and exporters labelled
406+
// the same entity correctly (#3179).
407+
//
408+
// Not every dropped keyword reaches this line: the four arms with
409+
// `has_geometry: false` are refused by `has_geometry_by_name` in
410+
// all three element producers, so this fix misses them. #3187 has
411+
// the trace, including the type-geometry gate that would make
412+
// THREE of the four eligible -- eligible, not certain.
410413
//
411414
// Recomputed from the SOURCE KEYWORD rather than recovered from
412-
// `entity.ifc_type`, because it cannot be: `IfcType::Unknown(u32)`
413-
// stores a CRC32 hash and `DecodedEntity` keeps no raw name, so
414-
// there is nothing to map back from. The bytes are already in hand
415-
// -- `content[start..end]` is the record `decode_and_cache` just
416-
// parsed -- so this is a ~20-byte scan to the first `(`, not a
417-
// re-read. Cheaper than widening the jobs wire, which is 3 u32 per
418-
// job across Rust and TS and would have paid marshalling cost on
419-
// every job to fix the few that are legacy.
415+
// `entity.ifc_type` -- why it cannot be recovered is on
416+
// `legacy_aware_ifc_type_from_record` itself. What is specific to
417+
// HERE: the bytes are already in hand -- `content[start..end]` is
418+
// the span THIS JOB carries, so this is a ~20-byte scan to the
419+
// first `(`, not a re-read. Note it is the job's span, not
420+
// necessarily what `decode_and_cache` parsed: on a cache hit
421+
// (`core/src/decoder.rs:443`) it returns the cached `Arc` without
422+
// reading `start`/`end` at all, so only the miss path bounds-checks
423+
// them. `content.get(..)` is fail-soft for that reason -- an
424+
// out-of-range span yields an empty slice and `decoded` is returned
425+
// unchanged.
426+
//
427+
// Cheaper than widening the jobs wire, which is 3 u32 per job
428+
// across Rust and TS and would have paid marshalling cost on every
429+
// job to fix the few that are legacy.
420430
let ifc_type = ifc_lite_core::legacy_aware_ifc_type_from_record(
421431
entity.ifc_type,
422432
content.get(start..end).unwrap_or_default(),

0 commit comments

Comments
 (0)