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)]
184#[non_exhaustive]
185pub struct BlobTextureDraft<'a> {
186    /// `RepeatS`: whether the texture tiles along S.
187    pub repeat_s: bool,
188    /// `RepeatT`: whether the texture tiles along T.
189    pub repeat_t: bool,
190    /// `Mode`: the texture application mode.
191    pub mode: Option<&'a str>,
192    /// `TextureTransform`: a 2D operator applied to the coordinates.
193    pub texture_transform: Option<EntityId>,
194    /// `Parameter`: optional, but non-empty when present.
195    pub parameter: &'a [&'a str],
196    /// `RasterFormat`: one of BMP, JPG, GIF, PNG.
197    pub raster_format: &'a str,
198    /// `RasterCode`: the hex payload, a whole number of bytes.
199    pub raster_code: &'a str,
200}
201
202/// Raster formats `SupportedRasterFormat` admits.
203///
204/// Closed and upper-case in the schema: 'png' is not 'PNG'.
205const RASTER_FORMATS: &[&str] = &["BMP", "JPG", "GIF", "PNG"];
206
207/// Stage an `IfcBlobTexture`, embedding the raster bytes in the file.
208///
209/// `raster_code` is the hex payload as it appears in STEP, without
210/// the surrounding quotes. `RasterCodeByteStream` requires it to be
211/// a whole number of bytes: STEP binary is bit-counted, so an odd
212/// hex-digit count is a partial byte no decoder can complete.
213///
214/// # Errors
215///
216/// Refuses a raster format outside the closed list, a payload that is
217/// not a whole number of bytes, a non-hex payload, an empty payload,
218/// and an empty parameter list.
219pub fn create_blob_texture(
220    tx: &mut Transaction,
221    schema: &Schema,
222    draft: BlobTextureDraft<'_>,
223) -> StyleResult<EntityId> {
224    let BlobTextureDraft {
225        repeat_s,
226        repeat_t,
227        mode,
228        texture_transform,
229        parameter,
230        raster_format,
231        raster_code,
232    } = draft;
233    const ENTITY: &str = "IfcBlobTexture";
234    if !RASTER_FORMATS.contains(&raster_format) {
235        return Err(invalid_authoring(ENTITY, "RasterFormat", raster_format));
236    }
237    if raster_code.is_empty() {
238        return Err(invalid_authoring(ENTITY, "RasterCode", "empty"));
239    }
240    // BLENGTH counts bits; one hex digit is four bits, so a whole
241    // byte is an even digit count.
242    if raster_code.len() % 2 != 0 {
243        return Err(invalid_authoring(ENTITY, "RasterCode", raster_code.len()));
244    }
245    if let Some(bad) = raster_code.chars().find(|c| !c.is_ascii_hexdigit()) {
246        return Err(invalid_authoring(ENTITY, "RasterCode", bad));
247    }
248    Ok(tx.create(build_named(
249        schema,
250        ENTITY,
251        blob_values(
252            repeat_s,
253            repeat_t,
254            mode,
255            texture_transform,
256            parameter,
257            raster_format,
258            raster_code,
259        )?,
260    )?))
261}
262
263#[allow(clippy::too_many_arguments)]
264fn blob_values(
265    repeat_s: bool,
266    repeat_t: bool,
267    mode: Option<&str>,
268    texture_transform: Option<EntityId>,
269    parameter: &[&str],
270    raster_format: &str,
271    raster_code: &str,
272) -> StyleResult<Vec<(&'static str, Value)>> {
273    const ENTITY: &str = "IfcBlobTexture";
274    // Parameter is OPTIONAL LIST [1:?]: absent is legal, present and
275    // empty is not.
276    let parameters = if parameter.is_empty() {
277        Value::Null
278    } else {
279        for text in parameter {
280            if text.trim().is_empty() {
281                return Err(invalid_authoring(ENTITY, "Parameter", "blank"));
282            }
283        }
284        Value::List(parameter.iter().map(|t| Value::Text((*t).into())).collect())
285    };
286    let mut values = vec![
287        ("RepeatS", Value::Bool(repeat_s)),
288        ("RepeatT", Value::Bool(repeat_t)),
289        ("RasterFormat", Value::Text(raster_format.into())),
290        ("RasterCode", Value::Binary(raster_code.into())),
291    ];
292    if !matches!(parameters, Value::Null) {
293        values.push(("Parameter", parameters));
294    }
295    optional_text(&mut values, "Mode", mode);
296    optional_reference(&mut values, "TextureTransform", texture_transform);
297    Ok(values)
298}
299
300/// Stage an `IfcSurfaceStyleWithTextures`.
301///
302/// # Errors
303///
304/// Refuses an empty texture list: the single attribute is
305/// `LIST [1:?]`, so a style with no textures is not a style.
306pub fn create_surface_style_with_textures(
307    tx: &mut Transaction,
308    schema: &Schema,
309    textures: &[EntityId],
310) -> StyleResult<EntityId> {
311    const ENTITY: &str = "IfcSurfaceStyleWithTextures";
312    if textures.is_empty() {
313        return Err(invalid_authoring(ENTITY, "Textures", "empty"));
314    }
315    let values = vec![(
316        "Textures",
317        Value::List(textures.iter().copied().map(Value::Ref).collect()),
318    )];
319    Ok(tx.create(build_named(schema, ENTITY, values)?))
320}
321
322/// Stage an `IfcIndexedPolygonalTextureMap`.
323///
324/// # Errors
325///
326/// Refuses an empty map list and an empty coordinate-index set,
327/// both declared `[1:?]`.
328pub fn create_indexed_polygonal_texture_map(
329    tx: &mut Transaction,
330    schema: &Schema,
331    maps: &[EntityId],
332    mapped_to: EntityId,
333    tex_coords: EntityId,
334    tex_coord_indices: &[EntityId],
335) -> StyleResult<EntityId> {
336    const ENTITY: &str = "IfcIndexedPolygonalTextureMap";
337    if maps.is_empty() {
338        return Err(invalid_authoring(ENTITY, "Maps", "empty"));
339    }
340    if tex_coord_indices.is_empty() {
341        return Err(invalid_authoring(ENTITY, "TexCoordIndices", "empty"));
342    }
343    let values = vec![
344        (
345            "Maps",
346            Value::List(maps.iter().copied().map(Value::Ref).collect()),
347        ),
348        ("MappedTo", Value::Ref(mapped_to)),
349        ("TexCoords", Value::Ref(tex_coords)),
350        (
351            "TexCoordIndices",
352            Value::List(tex_coord_indices.iter().copied().map(Value::Ref).collect()),
353        ),
354    ];
355    Ok(tx.create(build_named(schema, ENTITY, values)?))
356}
357
358/// `IfcTextAlignment`: closed and lower-case.
359const TEXT_ALIGNMENT: &[&str] = &["left", "right", "center", "justify"];
360
361/// `IfcTextDecoration`: closed and lower-case.
362const TEXT_DECORATION: &[&str] = &["none", "underline", "overline", "line-through", "blink"];
363
364/// `IfcTextTransformation`: closed and lower-case.
365const TEXT_TRANSFORMATION: &[&str] = &["capitalize", "uppercase", "lowercase", "none"];
366
367fn closed(
368    entity: &'static str,
369    attribute: &'static str,
370    value: Option<&str>,
371    permitted: &[&str],
372) -> StyleResult<Value> {
373    let Some(text) = value else {
374        return Ok(Value::Null);
375    };
376    // Case-sensitive: the schema lists 'none', so 'None' fails WR1.
377    if !permitted.contains(&text) {
378        return Err(invalid_authoring(entity, attribute, text));
379    }
380    Ok(Value::Text(text.into()))
381}
382
383/// A resolved `IfcSizeSelect` value.
384///
385/// The select admits six measure types, and a bare real is
386/// ambiguous between them. The caller names which one it means so
387/// the value is written typed rather than guessed.
388#[derive(Debug, Clone, Copy, PartialEq)]
389pub enum SizeValue<'a> {
390    /// `IfcDescriptiveMeasure`: a word such as 'small'.
391    Descriptive(&'a str),
392    /// `IfcLengthMeasure`: any finite length.
393    Length(f64),
394    /// `IfcPositiveLengthMeasure`: a length above zero.
395    PositiveLength(f64),
396    /// `IfcRatioMeasure`: any finite ratio.
397    Ratio(f64),
398    /// `IfcNormalisedRatioMeasure`: a ratio within 0 to 1.
399    NormalisedRatio(f64),
400    /// `IfcPositiveRatioMeasure`: a ratio above zero.
401    PositiveRatio(f64),
402}
403
404impl SizeValue<'_> {
405    fn into_value(self, entity: &'static str, attribute: &'static str) -> StyleResult<Value> {
406        let (type_name, inner) = match self {
407            Self::Descriptive(text) => {
408                if text.trim().is_empty() {
409                    return Err(invalid_authoring(entity, attribute, "blank"));
410                }
411                ("IFCDESCRIPTIVEMEASURE", Value::Text(text.into()))
412            }
413            Self::Length(v) => ("IFCLENGTHMEASURE", finite(entity, attribute, v)?),
414            Self::PositiveLength(v) => {
415                if v <= 0.0 {
416                    return Err(invalid_authoring(entity, attribute, v));
417                }
418                ("IFCPOSITIVELENGTHMEASURE", finite(entity, attribute, v)?)
419            }
420            Self::Ratio(v) => ("IFCRATIOMEASURE", finite(entity, attribute, v)?),
421            Self::NormalisedRatio(v) => {
422                if !(0.0..=1.0).contains(&v) {
423                    return Err(invalid_authoring(entity, attribute, v));
424                }
425                ("IFCNORMALISEDRATIOMEASURE", finite(entity, attribute, v)?)
426            }
427            Self::PositiveRatio(v) => {
428                if v <= 0.0 {
429                    return Err(invalid_authoring(entity, attribute, v));
430                }
431                ("IFCPOSITIVERATIOMEASURE", finite(entity, attribute, v)?)
432            }
433        };
434        Ok(Value::Typed {
435            type_name: type_name.into(),
436            value: Box::new(inner),
437        })
438    }
439}
440
441fn finite(entity: &'static str, attribute: &'static str, value: f64) -> StyleResult<Value> {
442    if !value.is_finite() {
443        return Err(invalid_authoring(entity, attribute, value));
444    }
445    Ok(Value::Real(value))
446}
447
448/// Attributes of an `IfcTextStyleTextModel`.
449#[derive(Debug, Clone, Copy, Default)]
450#[non_exhaustive]
451pub struct TextModelDraft<'a> {
452    /// `TextIndent`.
453    pub text_indent: Option<SizeValue<'a>>,
454    /// `TextAlign`, one of the closed lower-case alignment words.
455    pub text_align: Option<&'a str>,
456    /// `TextDecoration`, one of the closed lower-case decoration words.
457    pub text_decoration: Option<&'a str>,
458    /// `LetterSpacing`.
459    pub letter_spacing: Option<SizeValue<'a>>,
460    /// `WordSpacing`.
461    pub word_spacing: Option<SizeValue<'a>>,
462    /// `TextTransform`, one of the closed lower-case transform words.
463    pub text_transform: Option<&'a str>,
464    /// `LineHeight`.
465    pub line_height: Option<SizeValue<'a>>,
466}
467
468/// Stage an `IfcTextStyleTextModel`.
469///
470/// Every attribute is optional, so this writer enforces the value
471/// rules rather than presence: the three word-valued attributes are
472/// closed lower-case lists, and each size is a resolved select.
473///
474/// # Errors
475///
476/// Refuses a word outside its closed list, and a size value that
477/// violates the measure type the caller named.
478pub fn create_text_style_text_model(
479    tx: &mut Transaction,
480    schema: &Schema,
481    draft: TextModelDraft<'_>,
482) -> StyleResult<EntityId> {
483    const ENTITY: &str = "IfcTextStyleTextModel";
484    let values = vec![
485        ("TextIndent", size(ENTITY, "TextIndent", draft.text_indent)?),
486        (
487            "TextAlign",
488            closed(ENTITY, "TextAlign", draft.text_align, TEXT_ALIGNMENT)?,
489        ),
490        (
491            "TextDecoration",
492            closed(
493                ENTITY,
494                "TextDecoration",
495                draft.text_decoration,
496                TEXT_DECORATION,
497            )?,
498        ),
499        (
500            "LetterSpacing",
501            size(ENTITY, "LetterSpacing", draft.letter_spacing)?,
502        ),
503        (
504            "WordSpacing",
505            size(ENTITY, "WordSpacing", draft.word_spacing)?,
506        ),
507        (
508            "TextTransform",
509            closed(
510                ENTITY,
511                "TextTransform",
512                draft.text_transform,
513                TEXT_TRANSFORMATION,
514            )?,
515        ),
516        ("LineHeight", size(ENTITY, "LineHeight", draft.line_height)?),
517    ];
518    Ok(tx.create(build_named(schema, ENTITY, values)?))
519}
520
521fn size(
522    entity: &'static str,
523    attribute: &'static str,
524    value: Option<SizeValue<'_>>,
525) -> StyleResult<Value> {
526    value.map_or(Ok(Value::Null), |v| v.into_value(entity, attribute))
527}
528
529/// Stage an `IfcTextStyle`.
530///
531/// `text_font_style` is `IfcTextFontSelect` and is required: it
532/// resolves to either a pre-defined text font or an externally
533/// defined one, both of which this module can stage.
534///
535/// # Errors
536///
537/// Refuses a blank name.
538pub fn create_text_style(
539    tx: &mut Transaction,
540    schema: &Schema,
541    name: Option<&str>,
542    text_character_appearance: Option<EntityId>,
543    text_style: Option<EntityId>,
544    text_font_style: EntityId,
545    model_or_draughting: Option<bool>,
546) -> StyleResult<EntityId> {
547    const ENTITY: &str = "IfcTextStyle";
548    if name.is_some_and(|text| text.trim().is_empty()) {
549        return Err(invalid_authoring(ENTITY, "Name", "blank"));
550    }
551    let mut values = vec![("TextFontStyle", Value::Ref(text_font_style))];
552    optional_text(&mut values, "Name", name);
553    optional_reference(
554        &mut values,
555        "TextCharacterAppearance",
556        text_character_appearance,
557    );
558    optional_reference(&mut values, "TextStyle", text_style);
559    if let Some(flag) = model_or_draughting {
560        values.push(("ModelOrDraughting", Value::Bool(flag)));
561    }
562    Ok(tx.create(build_named(schema, ENTITY, values)?))
563}
564
565/// Stage an `IfcIndexedTriangleTextureMap`.
566///
567/// The triangle form differs from the polygonal one in its index
568/// slot: `TexCoordIndex` is a list of triples of *integers* into the
569/// coordinate list, not a list of index entities. Writing entity
570/// references there would produce a map whose every triangle is
571/// unresolvable, which is why the two forms have separate writers.
572///
573/// # Errors
574///
575/// Refuses an empty `maps` list (`LIST [1:?]`) and an index value of
576/// zero: `IfcPositiveInteger` is one-based.
577pub fn create_indexed_triangle_texture_map(
578    tx: &mut Transaction,
579    schema: &Schema,
580    maps: &[EntityId],
581    mapped_to: EntityId,
582    tex_coords: EntityId,
583    tex_coord_index: &[[i64; 3]],
584) -> StyleResult<EntityId> {
585    const ENTITY: &str = "IfcIndexedTriangleTextureMap";
586    if maps.is_empty() {
587        return Err(invalid_authoring(ENTITY, "Maps", "empty"));
588    }
589    for triangle in tex_coord_index {
590        if let Some(index) = triangle.iter().find(|value| **value < 1) {
591            return Err(invalid_authoring(
592                ENTITY,
593                "TexCoordIndex",
594                format!("{index} is not a positive one-based index"),
595            ));
596        }
597    }
598    let mut values = vec![
599        (
600            "Maps",
601            Value::List(maps.iter().copied().map(Value::Ref).collect()),
602        ),
603        ("MappedTo", Value::Ref(mapped_to)),
604        ("TexCoords", Value::Ref(tex_coords)),
605    ];
606    if !tex_coord_index.is_empty() {
607        values.push((
608            "TexCoordIndex",
609            Value::List(
610                tex_coord_index
611                    .iter()
612                    .map(|triangle| {
613                        Value::List(triangle.iter().copied().map(Value::Integer).collect())
614                    })
615                    .collect(),
616            ),
617        ));
618    }
619    Ok(tx.create(build_named(schema, ENTITY, values)?))
620}
621
622/// Stage an `IfcLightDistributionData`: one row of a photometric
623/// table.
624///
625/// A row fixes a main-plane angle and gives the intensity at each
626/// secondary angle. The two lists are read in parallel, so a row whose
627/// lengths disagree silently pairs an angle with the wrong intensity.
628/// That is checked here rather than left to the reader.
629///
630/// # Errors
631///
632/// Refuses empty lists (both are `LIST [1:?]`), lists of differing
633/// length, and non-finite values.
634pub fn create_light_distribution_data(
635    tx: &mut Transaction,
636    schema: &Schema,
637    main_plane_angle: f64,
638    secondary_plane_angles: &[f64],
639    luminous_intensities: &[f64],
640) -> StyleResult<EntityId> {
641    const ENTITY: &str = "IfcLightDistributionData";
642    if secondary_plane_angles.is_empty() || luminous_intensities.is_empty() {
643        return Err(invalid_authoring(ENTITY, "SecondaryPlaneAngle", "empty"));
644    }
645    if secondary_plane_angles.len() != luminous_intensities.len() {
646        return Err(invalid_authoring(
647            ENTITY,
648            "LuminousIntensity",
649            "expected one intensity per secondary angle",
650        ));
651    }
652    if !main_plane_angle.is_finite()
653        || secondary_plane_angles.iter().any(|v| !v.is_finite())
654        || luminous_intensities.iter().any(|v| !v.is_finite())
655    {
656        return Err(invalid_authoring(ENTITY, "MainPlaneAngle", "not finite"));
657    }
658    let reals = |values: &[f64]| Value::List(values.iter().copied().map(Value::Real).collect());
659    let values = vec![
660        ("MainPlaneAngle", Value::Real(main_plane_angle)),
661        ("SecondaryPlaneAngle", reals(secondary_plane_angles)),
662        ("LuminousIntensity", reals(luminous_intensities)),
663    ];
664    Ok(tx.create(build_named(schema, ENTITY, values)?))
665}
666
667/// Stage an `IfcLightIntensityDistribution`: a photometric table.
668///
669/// # Errors
670///
671/// Refuses an empty `distribution_data` list (`LIST [1:?]`) and a
672/// curve token the schema does not declare.
673pub fn create_light_intensity_distribution(
674    tx: &mut Transaction,
675    schema: &Schema,
676    light_distribution_curve: &str,
677    distribution_data: &[EntityId],
678) -> StyleResult<EntityId> {
679    const ENTITY: &str = "IfcLightIntensityDistribution";
680    if distribution_data.is_empty() {
681        return Err(invalid_authoring(ENTITY, "DistributionData", "empty"));
682    }
683    let declared = schema
684        .attributes(ENTITY)
685        .iter()
686        .find(|a| a.name.eq_ignore_ascii_case("LightDistributionCurve"))
687        .and_then(|a| schema.type_def(&a.type_name))
688        .is_some_and(|def| match &def.kind {
689            ifc_schema::TypeKind::Enumeration(values) => values
690                .iter()
691                .any(|v| v.eq_ignore_ascii_case(light_distribution_curve)),
692            _ => false,
693        });
694    if !declared {
695        return Err(invalid_authoring(
696            ENTITY,
697            "LightDistributionCurve",
698            light_distribution_curve,
699        ));
700    }
701    let values = vec![
702        (
703            "LightDistributionCurve",
704            Value::Enum(light_distribution_curve.into()),
705        ),
706        (
707            "DistributionData",
708            Value::List(distribution_data.iter().copied().map(Value::Ref).collect()),
709        ),
710    ];
711    Ok(tx.create(build_named(schema, ENTITY, values)?))
712}
713
714mod builders;