1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use super::output_cap::SymbolicAccumulator;
use super::rebase::RenderFrameRebase;
use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
use std::collections::HashMap;
use super::color::resolve_color_via_styles;
use super::primitives::{SymbolicText};
use super::transform::{compose_transforms, parse_axis2_placement_2d, Transform2D};
// ────────────────────────────────────────────────────────────────────────────
// Text extraction (IfcTextLiteral / IfcTextLiteralWithExtent).
// ────────────────────────────────────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
pub(super) fn extract_text_literal(
item: &DecodedEntity,
decoder: &mut EntityDecoder,
express_id: u32,
ifc_type: &str,
rep_identifier: &str,
unit_scale: f32,
transform: &Transform2D,
rebase: RenderFrameRebase,
styled_items: &HashMap<u32, Vec<u32>>,
out: &mut SymbolicAccumulator,
) {
let content = match item.get(0).and_then(|a| a.as_string()) {
Some(s) => s.to_string(),
None => return,
};
// `Placement` (IfcTextLiteral attribute 1) is MANDATORY per schema — not
// OPTIONAL like IfcProduct.ObjectPlacement (see transform.rs). A dangling
// ref or an absent/null attribute here is malformed data, not a
// legitimate zero, so both are genuine failures (#2256).
let placement_transform = match item.get_ref(1) {
Some(p_ref) => match decoder.decode_by_id(p_ref) {
Ok(p) => parse_axis2_placement_2d(&p, decoder, unit_scale),
Err(_) => Transform2D::unresolved(), // dangling ref (#2256)
},
None => Transform2D::unresolved(), // mandatory attr absent (#2256)
};
let composed = compose_transforms(transform, &placement_transform);
const CAP_TO_BOX_RATIO: f32 = 0.7;
const FALLBACK_CAP_HEIGHT_M: f32 = 0.18;
let height_model_units = if item.ifc_type == IfcType::IfcTextLiteralWithExtent {
item.get_ref(3)
.and_then(|extent_ref| decoder.decode_by_id(extent_ref).ok())
.and_then(|extent| extent.get(1).and_then(|a| a.as_float()))
.map(|h| (h as f32) * CAP_TO_BOX_RATIO)
.unwrap_or(FALLBACK_CAP_HEIGHT_M / unit_scale.max(1e-6))
} else {
FALLBACK_CAP_HEIGHT_M / unit_scale.max(1e-6)
};
let alignment = if item.ifc_type == IfcType::IfcTextLiteralWithExtent {
item.get(4)
.and_then(|a| a.as_string())
.unwrap_or("")
.to_string()
} else {
String::new()
};
let (wx, wy) = composed.transform_point(0.0, 0.0);
let plan = rebase.plan(wx, wy);
// Same finiteness hazard as items.rs's polyline/curve paths and fill.rs's
// boundary rings: a malformed STEP REAL (e.g. `1.E400`) in the placement
// chain reaches here as Infinity/NaN. Unlike `tz` (which legitimately
// carries `f32::NAN` as the "unresolved placement" sentinel via
// `Transform2D::unresolved()`), `tx`/`ty` never do — identity leaves them
// at 0.0 — so a non-finite (x, y) here is genuine malformed data, not a
// sentinel, and the whole text item is dropped rather than emitted with
// a corrupt position.
if !plan.0.is_finite() || !plan.1.is_finite() {
return;
}
let raw_scale = composed.scale();
// Height keeps a ZERO scale (the glyph collapses exactly as the symbol does);
// only a non-finite scale falls back. The direction below needs the stricter
// positive test, since it divides.
let text_scale = if raw_scale.is_finite() { raw_scale } else { 1.0 };
// Only the X-axis column (m00, m10) feeds the direction — NOT the Y
// column, which is where a mirroring MappingTarget's reflection lives
// (see `parse_cartesian_transformation_operator`). Glyphs therefore stay
// readable (non-mirrored) under a mirroring transform, same as before
// #1994: Axis1 is unaffected by an Axis2 mirror by construction, so this
// is not a special case — it falls out of reading only the X column.
//
// Through `plan_direction` for the same reason the anchor goes through
// `plan`: the frame can carry a rotation (a site-local response removes
// the site's yaw from its meshes), and a baseline left in world axes
// would have the glyphs running across a wall that is no longer parallel
// to them (#4706). It applies the handedness flip this used to spell
// inline as the minus on `m10`.
let dir = if raw_scale.is_finite() && raw_scale > 0.0 {
rebase.plan_direction(composed.m00 / raw_scale, composed.m10 / raw_scale)
} else {
// The degenerate-transform fallback is a RENDER-frame default ("read
// left to right"), not a world direction, so it is not re-based.
(1.0, 0.0)
};
let color = resolve_color_via_styles(item.id, styled_items, decoder)
.unwrap_or([0.05, 0.05, 0.05, 1.0]);
out.push_text(SymbolicText {
express_id,
ifc_type: ifc_type.to_string(),
x: plan.0,
y: plan.1,
// Direction must stay UNIT: `composed`'s linear block can carry a
// mapped-item Scale (#1985), and consumers read this pair as a bare
// direction vector. Any scale that is not finite and positive (a
// degenerate transform) falls back to +X rather than emitting NaN.
dir_x: dir.0,
dir_y: dir.1,
// The glyph height has to pick that same scale up here: a height never
// passes through `transform_point`.
height: height_model_units * unit_scale * text_scale,
content,
alignment,
world_y: rebase.elevation(composed.tz),
color,
target_px: 0.0,
representation: rep_identifier.to_string(),
});
}