Skip to main content

ifc_style/authoring/
external.rs

1//! Externally defined styles, texture coordinate indices, and
2//! the blob texture.
3//!
4//! # Why these sit apart from the presentation writers
5//!
6//! An externally defined style names a style held in another
7//! system: a hatch pattern in a CAD library, a font the file does
8//! not embed. The record carries only the reference, so nothing
9//! here validates the target exists -- that is the point of the
10//! entity, and inventing a check would refuse legitimate files.
11//!
12//! # The rules that are enforced
13//!
14//! `IfcBlobTexture` states both of the family's WHERE rules:
15//!
16//! ```text
17//! RasterCodeByteStream  : BLENGTH(RasterCode) MOD 8 = 0;
18//! SupportedRasterFormat : SELF.RasterFormat IN ['BMP','JPG','GIF','PNG'];
19//! ```
20//!
21//! A raster code that is not a whole number of bytes cannot be
22//! decoded by any reader, and a format outside the closed list
23//! names a decoder the schema does not promise.
24
25use ifc_model::{EntityId, Transaction, Value};
26use ifc_schema::Schema;
27
28use crate::error::StyleResult;
29
30use super::{build_named, invalid_authoring, optional_reference, optional_text};
31
32/// Which externally defined style is being named.
33///
34/// The three share one attribute shape, so a single writer covers
35/// them; the tag keeps a caller from spelling the type name itself.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ExternalStyleKind {
38    /// `IfcExternallyDefinedHatchStyle`: a fill pattern held elsewhere.
39    HatchStyle,
40    /// `IfcExternallyDefinedSurfaceStyle`: a surface appearance held elsewhere.
41    SurfaceStyle,
42    /// `IfcExternallyDefinedTextFont`: a font the file does not embed.
43    TextFont,
44}
45
46impl ExternalStyleKind {
47    fn type_name(self) -> &'static str {
48        match self {
49            Self::HatchStyle => "IfcExternallyDefinedHatchStyle",
50            Self::SurfaceStyle => "IfcExternallyDefinedSurfaceStyle",
51            Self::TextFont => "IfcExternallyDefinedTextFont",
52        }
53    }
54}
55
56/// Stage an externally defined style reference.
57///
58/// All three attributes are optional in the schema, but a reference
59/// naming nothing at all identifies no external resource: it would
60/// round-trip as three nulls and resolve for no reader. At least one
61/// of location, identification, or name must be given.
62///
63/// # Errors
64///
65/// Refuses a reference with no locating attribute set, and a blank
66/// string in any attribute that is supplied.
67pub fn create_externally_defined_style(
68    tx: &mut Transaction,
69    schema: &Schema,
70    kind: ExternalStyleKind,
71    location: Option<&str>,
72    identification: Option<&str>,
73    name: Option<&str>,
74) -> StyleResult<EntityId> {
75    let entity = kind.type_name();
76    for (attribute, value) in [
77        ("Location", location),
78        ("Identification", identification),
79        ("Name", name),
80    ] {
81        if value.is_some_and(|text| text.trim().is_empty()) {
82            return Err(invalid_authoring(entity, attribute, "blank"));
83        }
84    }
85    if location.is_none() && identification.is_none() && name.is_none() {
86        return Err(invalid_authoring(entity, "Location", "no attribute set"));
87    }
88    let mut values = Vec::new();
89    optional_text(&mut values, "Location", location);
90    optional_text(&mut values, "Identification", identification);
91    optional_text(&mut values, "Name", name);
92    Ok(tx.create(build_named(schema, entity, values)?))
93}
94
95fn index_list(
96    entity: &'static str,
97    attribute: &'static str,
98    indices: &[i64],
99) -> StyleResult<Value> {
100    if indices.len() < 3 {
101        return Err(invalid_authoring(entity, attribute, indices.len()));
102    }
103    // IfcPositiveInteger is one-based: 0 is not a valid index and
104    // accepting it would silently shift every lookup by one.
105    if let Some(bad) = indices.iter().find(|index| **index < 1) {
106        return Err(invalid_authoring(entity, attribute, bad));
107    }
108    Ok(Value::List(
109        indices.iter().copied().map(Value::Integer).collect(),
110    ))
111}
112
113/// Stage an `IfcTextureCoordinateIndices`.
114///
115/// # Errors
116///
117/// Refuses fewer than three indices, and any index below 1.
118pub fn create_texture_coordinate_indices(
119    tx: &mut Transaction,
120    schema: &Schema,
121    tex_coord_index: &[i64],
122    tex_coords_of: EntityId,
123) -> StyleResult<EntityId> {
124    const ENTITY: &str = "IfcTextureCoordinateIndices";
125    let values = vec![
126        (
127            "TexCoordIndex",
128            index_list(ENTITY, "TexCoordIndex", tex_coord_index)?,
129        ),
130        ("TexCoordsOf", Value::Ref(tex_coords_of)),
131    ];
132    Ok(tx.create(build_named(schema, ENTITY, values)?))
133}
134
135/// Stage an `IfcTextureCoordinateIndicesWithVoids`.
136///
137/// `inner_tex_coord_indices` is `LIST [1:?] OF LIST [3:?] OF UNIQUE`:
138/// each void is its own loop of at least three indices, and the
139/// indices within one void must be distinct. A repeated index inside
140/// a void collapses two corners of the hole onto one texture
141/// coordinate, which parses and renders wrongly.
142///
143/// # Errors
144///
145/// Refuses an outer list with no voids, any void with fewer than
146/// three indices, any index below 1, and a repeated index within a
147/// single void.
148pub fn create_texture_coordinate_indices_with_voids(
149    tx: &mut Transaction,
150    schema: &Schema,
151    tex_coord_index: &[i64],
152    tex_coords_of: EntityId,
153    inner_tex_coord_indices: &[&[i64]],
154) -> StyleResult<EntityId> {
155    const ENTITY: &str = "IfcTextureCoordinateIndicesWithVoids";
156    if inner_tex_coord_indices.is_empty() {
157        return Err(invalid_authoring(ENTITY, "InnerTexCoordIndices", "empty"));
158    }
159    let mut inner = Vec::with_capacity(inner_tex_coord_indices.len());
160    for void in inner_tex_coord_indices {
161        let list = index_list(ENTITY, "InnerTexCoordIndices", void)?;
162        // UNIQUE is declared on the inner list, so duplicates are
163        // checked per void rather than across the whole set.
164        for (position, index) in void.iter().enumerate() {
165            if void[..position].contains(index) {
166                return Err(invalid_authoring(ENTITY, "InnerTexCoordIndices", index));
167            }
168        }
169        inner.push(list);
170    }
171    let values = vec![
172        (
173            "TexCoordIndex",
174            index_list(ENTITY, "TexCoordIndex", tex_coord_index)?,
175        ),
176        ("TexCoordsOf", Value::Ref(tex_coords_of)),
177        ("InnerTexCoordIndices", Value::List(inner)),
178    ];
179    Ok(tx.create(build_named(schema, ENTITY, values)?))
180}
181
182/// Attributes of an `IfcBlobTexture`.
183#[derive(Debug, Clone, Copy, Default)]
184pub struct BlobTextureDraft<'a> {
185    /// `RepeatS`: whether the texture tiles along S.
186    pub repeat_s: bool,
187    /// `RepeatT`: whether the texture tiles along T.
188    pub repeat_t: bool,
189    /// `Mode`: the texture application mode.
190    pub mode: Option<&'a str>,
191    /// `TextureTransform`: a 2D operator applied to the coordinates.
192    pub texture_transform: Option<EntityId>,
193    /// `Parameter`: optional, but non-empty when present.
194    pub parameter: &'a [&'a str],
195    /// `RasterFormat`: one of BMP, JPG, GIF, PNG.
196    pub raster_format: &'a str,
197    /// `RasterCode`: the hex payload, a whole number of bytes.
198    pub raster_code: &'a str,
199}
200/// Raster formats `SupportedRasterFormat` admits.
201///
202/// Closed and upper-case in the schema: 'png' is not 'PNG'.
203const RASTER_FORMATS: &[&str] = &["BMP", "JPG", "GIF", "PNG"];
204
205/// Stage an `IfcBlobTexture`, embedding the raster bytes in the file.
206///
207/// `raster_code` is the hex payload as it appears in STEP, without
208/// the surrounding quotes. `RasterCodeByteStream` requires it to be
209/// a whole number of bytes: STEP binary is bit-counted, so an odd
210/// hex-digit count is a partial byte no decoder can complete.
211///
212/// # Errors
213///
214/// Refuses a raster format outside the closed list, a payload that is
215/// not a whole number of bytes, a non-hex payload, an empty payload,
216/// and an empty parameter list.
217pub fn create_blob_texture(
218    tx: &mut Transaction,
219    schema: &Schema,
220    draft: BlobTextureDraft<'_>,
221) -> StyleResult<EntityId> {
222    let BlobTextureDraft {
223        repeat_s,
224        repeat_t,
225        mode,
226        texture_transform,
227        parameter,
228        raster_format,
229        raster_code,
230    } = draft;
231    const ENTITY: &str = "IfcBlobTexture";
232    if !RASTER_FORMATS.contains(&raster_format) {
233        return Err(invalid_authoring(ENTITY, "RasterFormat", raster_format));
234    }
235    if raster_code.is_empty() {
236        return Err(invalid_authoring(ENTITY, "RasterCode", "empty"));
237    }
238    // BLENGTH counts bits; one hex digit is four bits, so a whole
239    // byte is an even digit count.
240    if raster_code.len() % 2 != 0 {
241        return Err(invalid_authoring(ENTITY, "RasterCode", raster_code.len()));
242    }
243    if let Some(bad) = raster_code.chars().find(|c| !c.is_ascii_hexdigit()) {
244        return Err(invalid_authoring(ENTITY, "RasterCode", bad));
245    }
246    Ok(tx.create(build_named(
247        schema,
248        ENTITY,
249        blob_values(
250            repeat_s,
251            repeat_t,
252            mode,
253            texture_transform,
254            parameter,
255            raster_format,
256            raster_code,
257        )?,
258    )?))
259}
260
261#[allow(clippy::too_many_arguments)]
262fn blob_values(
263    repeat_s: bool,
264    repeat_t: bool,
265    mode: Option<&str>,
266    texture_transform: Option<EntityId>,
267    parameter: &[&str],
268    raster_format: &str,
269    raster_code: &str,
270) -> StyleResult<Vec<(&'static str, Value)>> {
271    const ENTITY: &str = "IfcBlobTexture";
272    // Parameter is OPTIONAL LIST [1:?]: absent is legal, present and
273    // empty is not.
274    let parameters = if parameter.is_empty() {
275        Value::Null
276    } else {
277        for text in parameter {
278            if text.trim().is_empty() {
279                return Err(invalid_authoring(ENTITY, "Parameter", "blank"));
280            }
281        }
282        Value::List(parameter.iter().map(|t| Value::Text((*t).into())).collect())
283    };
284    let mut values = vec![
285        ("RepeatS", Value::Bool(repeat_s)),
286        ("RepeatT", Value::Bool(repeat_t)),
287        ("RasterFormat", Value::Text(raster_format.into())),
288        ("RasterCode", Value::Binary(raster_code.into())),
289    ];
290    if !matches!(parameters, Value::Null) {
291        values.push(("Parameter", parameters));
292    }
293    optional_text(&mut values, "Mode", mode);
294    optional_reference(&mut values, "TextureTransform", texture_transform);
295    Ok(values)
296}
297
298/// Stage an `IfcSurfaceStyleWithTextures`.
299///
300/// # Errors
301///
302/// Refuses an empty texture list: the single attribute is
303/// `LIST [1:?]`, so a style with no textures is not a style.
304pub fn create_surface_style_with_textures(
305    tx: &mut Transaction,
306    schema: &Schema,
307    textures: &[EntityId],
308) -> StyleResult<EntityId> {
309    const ENTITY: &str = "IfcSurfaceStyleWithTextures";
310    if textures.is_empty() {
311        return Err(invalid_authoring(ENTITY, "Textures", "empty"));
312    }
313    let values = vec![(
314        "Textures",
315        Value::List(textures.iter().copied().map(Value::Ref).collect()),
316    )];
317    Ok(tx.create(build_named(schema, ENTITY, values)?))
318}
319
320/// Stage an `IfcIndexedPolygonalTextureMap`.
321///
322/// # Errors
323///
324/// Refuses an empty map list and an empty coordinate-index set,
325/// both declared `[1:?]`.
326pub fn create_indexed_polygonal_texture_map(
327    tx: &mut Transaction,
328    schema: &Schema,
329    maps: &[EntityId],
330    mapped_to: EntityId,
331    tex_coords: EntityId,
332    tex_coord_indices: &[EntityId],
333) -> StyleResult<EntityId> {
334    const ENTITY: &str = "IfcIndexedPolygonalTextureMap";
335    if maps.is_empty() {
336        return Err(invalid_authoring(ENTITY, "Maps", "empty"));
337    }
338    if tex_coord_indices.is_empty() {
339        return Err(invalid_authoring(ENTITY, "TexCoordIndices", "empty"));
340    }
341    let values = vec![
342        (
343            "Maps",
344            Value::List(maps.iter().copied().map(Value::Ref).collect()),
345        ),
346        ("MappedTo", Value::Ref(mapped_to)),
347        ("TexCoords", Value::Ref(tex_coords)),
348        (
349            "TexCoordIndices",
350            Value::List(tex_coord_indices.iter().copied().map(Value::Ref).collect()),
351        ),
352    ];
353    Ok(tx.create(build_named(schema, ENTITY, values)?))
354}
355
356/// `IfcTextAlignment`: closed and lower-case.
357const TEXT_ALIGNMENT: &[&str] = &["left", "right", "center", "justify"];
358
359/// `IfcTextDecoration`: closed and lower-case.
360const TEXT_DECORATION: &[&str] = &["none", "underline", "overline", "line-through", "blink"];
361
362/// `IfcTextTransformation`: closed and lower-case.
363const TEXT_TRANSFORMATION: &[&str] = &["capitalize", "uppercase", "lowercase", "none"];
364
365fn closed(
366    entity: &'static str,
367    attribute: &'static str,
368    value: Option<&str>,
369    permitted: &[&str],
370) -> StyleResult<Value> {
371    let Some(text) = value else {
372        return Ok(Value::Null);
373    };
374    // Case-sensitive: the schema lists 'none', so 'None' fails WR1.
375    if !permitted.contains(&text) {
376        return Err(invalid_authoring(entity, attribute, text));
377    }
378    Ok(Value::Text(text.into()))
379}
380
381/// A resolved `IfcSizeSelect` value.
382///
383/// The select admits six measure types, and a bare real is
384/// ambiguous between them. The caller names which one it means so
385/// the value is written typed rather than guessed.
386#[derive(Debug, Clone, Copy, PartialEq)]
387pub enum SizeValue<'a> {
388    /// `IfcDescriptiveMeasure`: a word such as 'small'.
389    Descriptive(&'a str),
390    /// `IfcLengthMeasure`: any finite length.
391    Length(f64),
392    /// `IfcPositiveLengthMeasure`: a length above zero.
393    PositiveLength(f64),
394    /// `IfcRatioMeasure`: any finite ratio.
395    Ratio(f64),
396    /// `IfcNormalisedRatioMeasure`: a ratio within 0 to 1.
397    NormalisedRatio(f64),
398    /// `IfcPositiveRatioMeasure`: a ratio above zero.
399    PositiveRatio(f64),
400}
401
402impl SizeValue<'_> {
403    fn into_value(self, entity: &'static str, attribute: &'static str) -> StyleResult<Value> {
404        let (type_name, inner) = match self {
405            Self::Descriptive(text) => {
406                if text.trim().is_empty() {
407                    return Err(invalid_authoring(entity, attribute, "blank"));
408                }
409                ("IFCDESCRIPTIVEMEASURE", Value::Text(text.into()))
410            }
411            Self::Length(v) => ("IFCLENGTHMEASURE", finite(entity, attribute, v)?),
412            Self::PositiveLength(v) => {
413                if v <= 0.0 {
414                    return Err(invalid_authoring(entity, attribute, v));
415                }
416                ("IFCPOSITIVELENGTHMEASURE", finite(entity, attribute, v)?)
417            }
418            Self::Ratio(v) => ("IFCRATIOMEASURE", finite(entity, attribute, v)?),
419            Self::NormalisedRatio(v) => {
420                if !(0.0..=1.0).contains(&v) {
421                    return Err(invalid_authoring(entity, attribute, v));
422                }
423                ("IFCNORMALISEDRATIOMEASURE", finite(entity, attribute, v)?)
424            }
425            Self::PositiveRatio(v) => {
426                if v <= 0.0 {
427                    return Err(invalid_authoring(entity, attribute, v));
428                }
429                ("IFCPOSITIVERATIOMEASURE", finite(entity, attribute, v)?)
430            }
431        };
432        Ok(Value::Typed {
433            type_name: type_name.into(),
434            value: Box::new(inner),
435        })
436    }
437}
438
439fn finite(entity: &'static str, attribute: &'static str, value: f64) -> StyleResult<Value> {
440    if !value.is_finite() {
441        return Err(invalid_authoring(entity, attribute, value));
442    }
443    Ok(Value::Real(value))
444}
445
446/// Attributes of an `IfcTextStyleTextModel`.
447#[derive(Debug, Clone, Copy, Default)]
448pub struct TextModelDraft<'a> {
449    /// `TextIndent`.
450    pub text_indent: Option<SizeValue<'a>>,
451    /// `TextAlign`, one of the closed lower-case alignment words.
452    pub text_align: Option<&'a str>,
453    /// `TextDecoration`, one of the closed lower-case decoration words.
454    pub text_decoration: Option<&'a str>,
455    /// `LetterSpacing`.
456    pub letter_spacing: Option<SizeValue<'a>>,
457    /// `WordSpacing`.
458    pub word_spacing: Option<SizeValue<'a>>,
459    /// `TextTransform`, one of the closed lower-case transform words.
460    pub text_transform: Option<&'a str>,
461    /// `LineHeight`.
462    pub line_height: Option<SizeValue<'a>>,
463}
464
465/// Stage an `IfcTextStyleTextModel`.
466///
467/// Every attribute is optional, so this writer enforces the value
468/// rules rather than presence: the three word-valued attributes are
469/// closed lower-case lists, and each size is a resolved select.
470///
471/// # Errors
472///
473/// Refuses a word outside its closed list, and a size value that
474/// violates the measure type the caller named.
475pub fn create_text_style_text_model(
476    tx: &mut Transaction,
477    schema: &Schema,
478    draft: TextModelDraft<'_>,
479) -> StyleResult<EntityId> {
480    const ENTITY: &str = "IfcTextStyleTextModel";
481    let values = vec![
482        ("TextIndent", size(ENTITY, "TextIndent", draft.text_indent)?),
483        (
484            "TextAlign",
485            closed(ENTITY, "TextAlign", draft.text_align, TEXT_ALIGNMENT)?,
486        ),
487        (
488            "TextDecoration",
489            closed(
490                ENTITY,
491                "TextDecoration",
492                draft.text_decoration,
493                TEXT_DECORATION,
494            )?,
495        ),
496        (
497            "LetterSpacing",
498            size(ENTITY, "LetterSpacing", draft.letter_spacing)?,
499        ),
500        (
501            "WordSpacing",
502            size(ENTITY, "WordSpacing", draft.word_spacing)?,
503        ),
504        (
505            "TextTransform",
506            closed(
507                ENTITY,
508                "TextTransform",
509                draft.text_transform,
510                TEXT_TRANSFORMATION,
511            )?,
512        ),
513        ("LineHeight", size(ENTITY, "LineHeight", draft.line_height)?),
514    ];
515    Ok(tx.create(build_named(schema, ENTITY, values)?))
516}
517
518fn size(
519    entity: &'static str,
520    attribute: &'static str,
521    value: Option<SizeValue<'_>>,
522) -> StyleResult<Value> {
523    value.map_or(Ok(Value::Null), |v| v.into_value(entity, attribute))
524}
525
526/// Stage an `IfcTextStyle`.
527///
528/// `text_font_style` is `IfcTextFontSelect` and is required: it
529/// resolves to either a pre-defined text font or an externally
530/// defined one, both of which this module can stage.
531///
532/// # Errors
533///
534/// Refuses a blank name.
535pub fn create_text_style(
536    tx: &mut Transaction,
537    schema: &Schema,
538    name: Option<&str>,
539    text_character_appearance: Option<EntityId>,
540    text_style: Option<EntityId>,
541    text_font_style: EntityId,
542    model_or_draughting: Option<bool>,
543) -> StyleResult<EntityId> {
544    const ENTITY: &str = "IfcTextStyle";
545    if name.is_some_and(|text| text.trim().is_empty()) {
546        return Err(invalid_authoring(ENTITY, "Name", "blank"));
547    }
548    let mut values = vec![("TextFontStyle", Value::Ref(text_font_style))];
549    optional_text(&mut values, "Name", name);
550    optional_reference(
551        &mut values,
552        "TextCharacterAppearance",
553        text_character_appearance,
554    );
555    optional_reference(&mut values, "TextStyle", text_style);
556    if let Some(flag) = model_or_draughting {
557        values.push(("ModelOrDraughting", Value::Bool(flag)));
558    }
559    Ok(tx.create(build_named(schema, ENTITY, values)?))
560}
561
562/// Stage an `IfcIndexedTriangleTextureMap`.
563///
564/// The triangle form differs from the polygonal one in its index
565/// slot: `TexCoordIndex` is a list of triples of *integers* into the
566/// coordinate list, not a list of index entities. Writing entity
567/// references there would produce a map whose every triangle is
568/// unresolvable, which is why the two forms have separate writers.
569///
570/// # Errors
571///
572/// Refuses an empty `maps` list (`LIST [1:?]`) and an index value of
573/// zero: `IfcPositiveInteger` is one-based.
574pub fn create_indexed_triangle_texture_map(
575    tx: &mut Transaction,
576    schema: &Schema,
577    maps: &[EntityId],
578    mapped_to: EntityId,
579    tex_coords: EntityId,
580    tex_coord_index: &[[i64; 3]],
581) -> StyleResult<EntityId> {
582    const ENTITY: &str = "IfcIndexedTriangleTextureMap";
583    if maps.is_empty() {
584        return Err(invalid_authoring(ENTITY, "Maps", "empty"));
585    }
586    for triangle in tex_coord_index {
587        if let Some(index) = triangle.iter().find(|value| **value < 1) {
588            return Err(invalid_authoring(
589                ENTITY,
590                "TexCoordIndex",
591                format!("{index} is not a positive one-based index"),
592            ));
593        }
594    }
595    let mut values = vec![
596        (
597            "Maps",
598            Value::List(maps.iter().copied().map(Value::Ref).collect()),
599        ),
600        ("MappedTo", Value::Ref(mapped_to)),
601        ("TexCoords", Value::Ref(tex_coords)),
602    ];
603    if !tex_coord_index.is_empty() {
604        values.push((
605            "TexCoordIndex",
606            Value::List(
607                tex_coord_index
608                    .iter()
609                    .map(|triangle| {
610                        Value::List(triangle.iter().copied().map(Value::Integer).collect())
611                    })
612                    .collect(),
613            ),
614        ));
615    }
616    Ok(tx.create(build_named(schema, ENTITY, values)?))
617}
618
619/// Stage an `IfcLightDistributionData`: one row of a photometric
620/// table.
621///
622/// A row fixes a main-plane angle and gives the intensity at each
623/// secondary angle. The two lists are read in parallel, so a row whose
624/// lengths disagree silently pairs an angle with the wrong intensity.
625/// That is checked here rather than left to the reader.
626///
627/// # Errors
628///
629/// Refuses empty lists (both are `LIST [1:?]`), lists of differing
630/// length, and non-finite values.
631pub fn create_light_distribution_data(
632    tx: &mut Transaction,
633    schema: &Schema,
634    main_plane_angle: f64,
635    secondary_plane_angles: &[f64],
636    luminous_intensities: &[f64],
637) -> StyleResult<EntityId> {
638    const ENTITY: &str = "IfcLightDistributionData";
639    if secondary_plane_angles.is_empty() || luminous_intensities.is_empty() {
640        return Err(invalid_authoring(ENTITY, "SecondaryPlaneAngle", "empty"));
641    }
642    if secondary_plane_angles.len() != luminous_intensities.len() {
643        return Err(invalid_authoring(
644            ENTITY,
645            "LuminousIntensity",
646            "expected one intensity per secondary angle",
647        ));
648    }
649    if !main_plane_angle.is_finite()
650        || secondary_plane_angles.iter().any(|v| !v.is_finite())
651        || luminous_intensities.iter().any(|v| !v.is_finite())
652    {
653        return Err(invalid_authoring(ENTITY, "MainPlaneAngle", "not finite"));
654    }
655    let reals = |values: &[f64]| Value::List(values.iter().copied().map(Value::Real).collect());
656    let values = vec![
657        ("MainPlaneAngle", Value::Real(main_plane_angle)),
658        ("SecondaryPlaneAngle", reals(secondary_plane_angles)),
659        ("LuminousIntensity", reals(luminous_intensities)),
660    ];
661    Ok(tx.create(build_named(schema, ENTITY, values)?))
662}
663
664/// Stage an `IfcLightIntensityDistribution`: a photometric table.
665///
666/// # Errors
667///
668/// Refuses an empty `distribution_data` list (`LIST [1:?]`) and a
669/// curve token the schema does not declare.
670pub fn create_light_intensity_distribution(
671    tx: &mut Transaction,
672    schema: &Schema,
673    light_distribution_curve: &str,
674    distribution_data: &[EntityId],
675) -> StyleResult<EntityId> {
676    const ENTITY: &str = "IfcLightIntensityDistribution";
677    if distribution_data.is_empty() {
678        return Err(invalid_authoring(ENTITY, "DistributionData", "empty"));
679    }
680    let declared = schema
681        .attributes(ENTITY)
682        .iter()
683        .find(|a| a.name.eq_ignore_ascii_case("LightDistributionCurve"))
684        .and_then(|a| schema.type_def(&a.type_name))
685        .is_some_and(|def| match &def.kind {
686            ifc_schema::TypeKind::Enumeration(values) => values
687                .iter()
688                .any(|v| v.eq_ignore_ascii_case(light_distribution_curve)),
689            _ => false,
690        });
691    if !declared {
692        return Err(invalid_authoring(
693            ENTITY,
694            "LightDistributionCurve",
695            light_distribution_curve,
696        ));
697    }
698    let values = vec![
699        (
700            "LightDistributionCurve",
701            Value::Enum(light_distribution_curve.into()),
702        ),
703        (
704            "DistributionData",
705            Value::List(distribution_data.iter().copied().map(Value::Ref).collect()),
706        ),
707    ];
708    Ok(tx.create(build_named(schema, ENTITY, values)?))
709}