Skip to main content

ifc_style/authoring/
light.rs

1//! Light sources and the remaining surface-style elements.
2//!
3//! # Normalised ratios are bounded, and the bound is load-bearing
4//!
5//! `AmbientIntensity`, `Intensity` and `Transparency` are all
6//! `IfcNormalisedRatioMeasure`, which IFC defines as 0.0 to 1.0
7//! inclusive. A renderer handed 2.5 either clamps it or blows out the
8//! image, and neither is what the file said. The existing
9//! [`super::validate_ratio`] helper already enforces this for
10//! shading, so the light sources reuse it rather than restating the
11//! bound.
12//!
13//! # A spot light's two angles are not interchangeable
14//!
15//! `SpreadAngle` is the cone the light fills; `BeamWidthAngle` is the
16//! cone at full intensity. Both are `IfcPositivePlaneAngleMeasure`, so
17//! neither may be zero, and the schema does not require one to be
18//! larger than the other -- some exporters write a beam wider than the
19//! spread to mean "no falloff". Refusing that would reject valid
20//! files, so the writer checks positivity and leaves the relationship
21//! alone.
22
23use ifc_model::{EntityId, Model, Transaction, Value};
24use ifc_schema::Schema;
25
26use crate::authoring::{build_named, invalid_authoring, validate_ratio, validate_ref};
27use crate::StyleResult;
28
29/// The four attributes every `IfcLightSource` carries.
30#[derive(Debug, Clone, Copy)]
31pub struct LightSourceDraft<'a> {
32    /// `Name`.
33    pub name: Option<&'a str>,
34    /// `LightColour`: an `IfcColourRgb`. Required.
35    pub light_colour: EntityId,
36    /// `AmbientIntensity`, a normalised ratio.
37    pub ambient_intensity: Option<f64>,
38    /// `Intensity`, a normalised ratio.
39    pub intensity: Option<f64>,
40}
41
42/// Attenuation over distance, as three coefficients.
43///
44/// The renderer divides by `constant + distance * d + quadric * d^2`.
45/// All three being zero makes that divisor zero, so the combination is
46/// refused.
47#[derive(Debug, Clone, Copy)]
48pub struct Attenuation {
49    /// `ConstantAttenuation`.
50    pub constant: f64,
51    /// `DistanceAttenuation`.
52    pub distance: f64,
53    /// `QuadricAttenuation`.
54    pub quadric: f64,
55}
56
57fn check_light(entity: &'static str, draft: &LightSourceDraft<'_>) -> StyleResult<()> {
58    if let Some(value) = draft.ambient_intensity {
59        validate_ratio(entity, "AmbientIntensity", value)?;
60    }
61    if let Some(value) = draft.intensity {
62        validate_ratio(entity, "Intensity", value)?;
63    }
64    Ok(())
65}
66
67fn light_values<'a>(draft: &LightSourceDraft<'a>) -> Vec<(&'static str, Value)> {
68    let mut values = vec![("LightColour", Value::Ref(draft.light_colour))];
69    if let Some(name) = draft.name {
70        values.push(("Name", Value::Text(name.into())));
71    }
72    if let Some(value) = draft.ambient_intensity {
73        values.push(("AmbientIntensity", Value::Real(value)));
74    }
75    if let Some(value) = draft.intensity {
76        values.push(("Intensity", Value::Real(value)));
77    }
78    values
79}
80
81fn check_attenuation(entity: &'static str, attenuation: &Attenuation) -> StyleResult<()> {
82    let all = [
83        ("ConstantAttenuation", attenuation.constant),
84        ("DistanceAttenuation", attenuation.distance),
85        ("QuadricAttenuation", attenuation.quadric),
86    ];
87    for (attribute, value) in all {
88        if !value.is_finite() {
89            return Err(invalid_authoring(
90                entity,
91                attribute,
92                format!("expected a finite coefficient, got {value}"),
93            ));
94        }
95    }
96    if all.iter().all(|(_, value)| *value == 0.0) {
97        return Err(invalid_authoring(
98            entity,
99            "ConstantAttenuation",
100            "all three coefficients zero leaves no attenuation function",
101        ));
102    }
103    Ok(())
104}
105
106/// Stage an `IfcLightSourceAmbient`: light with no position or direction.
107///
108/// # Errors
109///
110/// Refuses an out-of-range normalised ratio and a `light_colour` that
111/// does not resolve to an `IfcColourRgb`.
112pub fn create_light_source_ambient(
113    tx: &mut Transaction,
114    model: &Model,
115    schema: &Schema,
116    draft: LightSourceDraft<'_>,
117) -> StyleResult<EntityId> {
118    check_light("IfcLightSourceAmbient", &draft)?;
119    validate_ref(tx, model, schema, draft.light_colour, "IfcColourRgb")?;
120    Ok(tx.create(build_named(
121        schema,
122        "IfcLightSourceAmbient",
123        light_values(&draft),
124    )?))
125}
126
127/// Stage an `IfcLightSourceDirectional`: parallel rays, no position.
128///
129/// # Errors
130///
131/// Refuses an out-of-range normalised ratio, and references that do
132/// not resolve to their declared types.
133pub fn create_light_source_directional(
134    tx: &mut Transaction,
135    model: &Model,
136    schema: &Schema,
137    draft: LightSourceDraft<'_>,
138    orientation: EntityId,
139) -> StyleResult<EntityId> {
140    check_light("IfcLightSourceDirectional", &draft)?;
141    validate_ref(tx, model, schema, draft.light_colour, "IfcColourRgb")?;
142    validate_ref(tx, model, schema, orientation, "IfcDirection")?;
143
144    let mut values = light_values(&draft);
145    values.push(("Orientation", Value::Ref(orientation)));
146    Ok(tx.create(build_named(schema, "IfcLightSourceDirectional", values)?))
147}
148
149/// Stage an `IfcLightSourcePositional`: a point light with falloff.
150///
151/// # Errors
152///
153/// Refuses an out-of-range normalised ratio, a non-positive radius,
154/// a non-finite or all-zero attenuation, and references that do not
155/// resolve to their declared types.
156pub fn create_light_source_positional(
157    tx: &mut Transaction,
158    model: &Model,
159    schema: &Schema,
160    draft: LightSourceDraft<'_>,
161    point: PointLight,
162) -> StyleResult<EntityId> {
163    let PointLight {
164        position,
165        radius,
166        attenuation,
167    } = point;
168    check_light("IfcLightSourcePositional", &draft)?;
169    if !radius.is_finite() || radius <= 0.0 {
170        return Err(invalid_authoring(
171            "IfcLightSourcePositional",
172            "Radius",
173            format!("expected a positive length, got {radius}"),
174        ));
175    }
176    check_attenuation("IfcLightSourcePositional", &attenuation)?;
177    validate_ref(tx, model, schema, draft.light_colour, "IfcColourRgb")?;
178    validate_ref(tx, model, schema, position, "IfcCartesianPoint")?;
179
180    let mut values = light_values(&draft);
181    values.extend([
182        ("Position", Value::Ref(position)),
183        ("Radius", Value::Real(radius)),
184        ("ConstantAttenuation", Value::Real(attenuation.constant)),
185        ("DistanceAttenuation", Value::Real(attenuation.distance)),
186        ("QuadricAttenuation", Value::Real(attenuation.quadric)),
187    ]);
188    Ok(tx.create(build_named(schema, "IfcLightSourcePositional", values)?))
189}
190
191/// Where a light sits and how it falls off.
192///
193/// Position, radius and attenuation are not independent knobs: they
194/// together describe one point emitter, and every entity carrying one
195/// carries all three. Grouping them keeps the spot-light signature
196/// honest rather than a seven-argument list where two adjacent `f64`
197/// parameters invite transposition.
198#[derive(Debug, Clone, Copy)]
199pub struct PointLight {
200    /// `Position`: an `IfcCartesianPoint`.
201    pub position: EntityId,
202    /// `Radius`: a positive length.
203    pub radius: f64,
204    /// The three attenuation coefficients.
205    pub attenuation: Attenuation,
206}
207
208impl Default for Attenuation {
209    /// No falloff: intensity is constant with distance.
210    ///
211    /// Deriving `Default` would give all three zeros, which makes the
212    /// attenuation polynomial evaluate to zero at every distance: a
213    /// light that emits nothing. The neutral value is constant = 1.
214    fn default() -> Self {
215        Self {
216            constant: 1.0,
217            distance: 0.0,
218            quadric: 0.0,
219        }
220    }
221}
222
223/// The cone geometry of a spot light.
224#[derive(Debug, Clone, Copy)]
225pub struct SpotCone {
226    /// `Orientation`: an `IfcDirection`.
227    pub orientation: EntityId,
228    /// `SpreadAngle`: the full cone, a positive plane angle.
229    pub spread_angle: f64,
230    /// `BeamWidthAngle`: the full-intensity cone, a positive angle.
231    pub beam_width_angle: f64,
232    /// `ConcentrationExponent`: falloff sharpness.
233    pub concentration_exponent: Option<f64>,
234}
235
236/// Stage an `IfcLightSourceSpot`.
237///
238/// # Errors
239///
240/// Everything [`create_light_source_positional`] refuses, plus a
241/// non-positive spread or beam width angle.
242pub fn create_light_source_spot(
243    tx: &mut Transaction,
244    model: &Model,
245    schema: &Schema,
246    draft: LightSourceDraft<'_>,
247    point: PointLight,
248    cone: SpotCone,
249) -> StyleResult<EntityId> {
250    let PointLight {
251        position,
252        radius,
253        attenuation,
254    } = point;
255    check_light("IfcLightSourceSpot", &draft)?;
256    if !radius.is_finite() || radius <= 0.0 {
257        return Err(invalid_authoring(
258            "IfcLightSourceSpot",
259            "Radius",
260            format!("expected a positive length, got {radius}"),
261        ));
262    }
263    check_attenuation("IfcLightSourceSpot", &attenuation)?;
264
265    for (attribute, value) in [
266        ("SpreadAngle", cone.spread_angle),
267        ("BeamWidthAngle", cone.beam_width_angle),
268    ] {
269        if !value.is_finite() || value <= 0.0 {
270            return Err(invalid_authoring(
271                "IfcLightSourceSpot",
272                attribute,
273                format!("expected a positive plane angle, got {value}"),
274            ));
275        }
276    }
277
278    validate_ref(tx, model, schema, draft.light_colour, "IfcColourRgb")?;
279    validate_ref(tx, model, schema, position, "IfcCartesianPoint")?;
280    validate_ref(tx, model, schema, cone.orientation, "IfcDirection")?;
281
282    let mut values = light_values(&draft);
283    values.extend([
284        ("Position", Value::Ref(position)),
285        ("Radius", Value::Real(radius)),
286        ("ConstantAttenuation", Value::Real(attenuation.constant)),
287        ("DistanceAttenuation", Value::Real(attenuation.distance)),
288        ("QuadricAttenuation", Value::Real(attenuation.quadric)),
289        ("Orientation", Value::Ref(cone.orientation)),
290        ("SpreadAngle", Value::Real(cone.spread_angle)),
291        ("BeamWidthAngle", Value::Real(cone.beam_width_angle)),
292    ]);
293    if let Some(value) = cone.concentration_exponent {
294        values.push(("ConcentrationExponent", Value::Real(value)));
295    }
296    Ok(tx.create(build_named(schema, "IfcLightSourceSpot", values)?))
297}
298
299/// Stage an `IfcSurfaceStyleLighting`: the four physical colour terms.
300///
301/// # Errors
302///
303/// Refuses any colour that does not resolve to an `IfcColourRgb`.
304pub fn create_surface_style_lighting(
305    tx: &mut Transaction,
306    model: &Model,
307    schema: &Schema,
308    diffuse_transmission: EntityId,
309    diffuse_reflection: EntityId,
310    transmission: EntityId,
311    reflectance: EntityId,
312) -> StyleResult<EntityId> {
313    for colour in [
314        diffuse_transmission,
315        diffuse_reflection,
316        transmission,
317        reflectance,
318    ] {
319        validate_ref(tx, model, schema, colour, "IfcColourRgb")?;
320    }
321
322    let values = vec![
323        (
324            "DiffuseTransmissionColour",
325            Value::Ref(diffuse_transmission),
326        ),
327        ("DiffuseReflectionColour", Value::Ref(diffuse_reflection)),
328        ("TransmissionColour", Value::Ref(transmission)),
329        ("ReflectanceColour", Value::Ref(reflectance)),
330    ];
331    Ok(tx.create(build_named(schema, "IfcSurfaceStyleLighting", values)?))
332}
333
334/// Stage an `IfcSurfaceStyleRefraction`.
335///
336/// # Errors
337///
338/// Refuses a refraction index below 1.0: no physical medium refracts
339/// less than a vacuum, and a value below one inverts the bend.
340pub fn create_surface_style_refraction(
341    tx: &mut Transaction,
342    schema: &Schema,
343    refraction_index: Option<f64>,
344    dispersion_factor: Option<f64>,
345) -> StyleResult<EntityId> {
346    if let Some(value) = refraction_index {
347        if !value.is_finite() || value < 1.0 {
348            return Err(invalid_authoring(
349                "IfcSurfaceStyleRefraction",
350                "RefractionIndex",
351                format!("expected at least 1.0, the vacuum index, got {value}"),
352            ));
353        }
354    }
355    if let Some(value) = dispersion_factor {
356        if !value.is_finite() {
357            return Err(invalid_authoring(
358                "IfcSurfaceStyleRefraction",
359                "DispersionFactor",
360                format!("expected a finite factor, got {value}"),
361            ));
362        }
363    }
364
365    let mut values = Vec::new();
366    if let Some(value) = refraction_index {
367        values.push(("RefractionIndex", Value::Real(value)));
368    }
369    if let Some(value) = dispersion_factor {
370        values.push(("DispersionFactor", Value::Real(value)));
371    }
372    Ok(tx.create(build_named(schema, "IfcSurfaceStyleRefraction", values)?))
373}
374
375/// The goniometric-only attributes of an `IfcLightSourceGoniometric`.
376#[derive(Debug, Clone, Copy)]
377pub struct GoniometricLight {
378    /// `Position`, an `IfcAxis2Placement3D`.
379    pub position: EntityId,
380    /// `ColourAppearance`, an `IfcColourRgb`.
381    pub colour_appearance: Option<EntityId>,
382    /// `ColourTemperature`, in kelvin.
383    pub colour_temperature: f64,
384    /// `LuminousFlux`, in lumen.
385    pub luminous_flux: f64,
386    /// `LightEmissionSource`, an `IfcLightEmissionSourceEnum` token.
387    pub emission_source: &'static str,
388    /// `LightDistributionDataSource`: an `IfcLightIntensityDistribution`
389    /// or an `IfcExternalReference`.
390    pub distribution_data_source: EntityId,
391}
392
393/// Stage an `IfcLightSourceGoniometric`.
394///
395/// A photometrically defined luminaire: rather than a radius or a
396/// cone, it carries a measured intensity distribution, which is what a
397/// lighting calculation actually needs.
398///
399/// # Errors
400///
401/// Refuses an out-of-range intensity, a colour reference that is not an
402/// `IfcColourRgb`, a non-positive colour temperature or luminous flux
403/// (both are absolute physical measures), and an emission-source token
404/// the schema does not declare.
405pub fn create_light_source_goniometric(
406    tx: &mut Transaction,
407    model: &Model,
408    schema: &Schema,
409    draft: LightSourceDraft<'_>,
410    light: GoniometricLight,
411) -> StyleResult<EntityId> {
412    const ENTITY: &str = "IfcLightSourceGoniometric";
413    check_light(ENTITY, &draft)?;
414    validate_ref(tx, model, schema, draft.light_colour, "IfcColourRgb")?;
415    if let Some(colour) = light.colour_appearance {
416        validate_ref(tx, model, schema, colour, "IfcColourRgb")?;
417    }
418    for (value, attribute) in [
419        (light.colour_temperature, "ColourTemperature"),
420        (light.luminous_flux, "LuminousFlux"),
421    ] {
422        // Both are absolute measures: zero kelvin or zero lumen is not
423        // a dim light, it is a light that cannot exist.
424        if !value.is_finite() || value <= 0.0 {
425            return Err(invalid_authoring(
426                ENTITY,
427                attribute,
428                format!("expected a positive measure, got {value}"),
429            ));
430        }
431    }
432    let declared = schema
433        .attributes(ENTITY)
434        .iter()
435        .find(|a| a.name.eq_ignore_ascii_case("LightEmissionSource"))
436        .and_then(|a| schema.type_def(&a.type_name))
437        .is_some_and(|def| match &def.kind {
438            ifc_schema::TypeKind::Enumeration(values) => values
439                .iter()
440                .any(|v| v.eq_ignore_ascii_case(light.emission_source)),
441            _ => false,
442        });
443    if !declared {
444        return Err(invalid_authoring(
445            ENTITY,
446            "LightEmissionSource",
447            light.emission_source,
448        ));
449    }
450
451    let mut values = light_values(&draft);
452    values.push(("Position", Value::Ref(light.position)));
453    if let Some(colour) = light.colour_appearance {
454        values.push(("ColourAppearance", Value::Ref(colour)));
455    }
456    values.push(("ColourTemperature", Value::Real(light.colour_temperature)));
457    values.push(("LuminousFlux", Value::Real(light.luminous_flux)));
458    values.push((
459        "LightEmissionSource",
460        Value::Enum(light.emission_source.into()),
461    ));
462    values.push((
463        "LightDistributionDataSource",
464        Value::Ref(light.distribution_data_source),
465    ));
466    Ok(tx.create(build_named(schema, ENTITY, values)?))
467}