Skip to main content

brep_kernel/feature_pipeline/assembly/
mapping.rs

1//! Constraint → mate mapping SHARED MACHINERY: selection-ref resolution
2//! against the scene's component registry, the [`map_constraint`] dispatch,
3//! orientation-preference handling (`preferredOppose` XOR
4//! reverse/opposeNormals), first-solve initialization, and the rigid-pose math
5//! shared with the write-back lane. The PER-TYPE `map` functions live with
6//! their constraints ([`super::constraints`], one module per type).
7//!
8//! # Selection-ref conventions (cross-lane contract)
9//!
10//! - Face/edge refs are the NAMESPACED scene names (`ACOMP2:Extrude1_top`).
11//! - A whole-component ref is the bare feature id (`ACOMP2`), or a nested
12//!   chain (`ACOMP3:ACOMP1`) owned by the OUTERMOST component at this level.
13//! - A vertex ref is `{solidName}@x,y,z` with the position in COMPONENT-LOCAL
14//!   coordinates (stable under pose changes; kernel vertices carry no names).
15//!   The picker builds it as `world_pick · component_transform⁻¹`.
16
17use super::ConstraintEntry;
18use crate::feature_pipeline::component::ComponentRecord;
19use crate::feature_pipeline::{Env, SceneMap};
20use crate::{
21    resolve_edge_selection, resolve_face_selection, resolve_named_selection,
22    resolve_vertex_selection, AffineTransform, MateAlign, MateKind, SelectionGeometry, Vec3,
23};
24
25/// A per-constraint failure carrying the requirements-§5 status word.
26#[derive(Debug, Clone)]
27pub(super) struct ConstraintFailure {
28    pub status: &'static str,
29    pub message: String,
30}
31
32impl ConstraintFailure {
33    pub fn new(status: &'static str, message: impl Into<String>) -> Self {
34        Self {
35            status,
36            message: message.into(),
37        }
38    }
39    pub(super) fn unsupported(message: impl Into<String>) -> Self {
40        Self::new("unsupported-selection", message)
41    }
42    pub(super) fn invalid(message: impl Into<String>) -> Self {
43        Self::new("invalid-selection", message)
44    }
45}
46
47// ===========================================================================
48// Element resolution
49// ===========================================================================
50
51/// One resolved constraint element: the owning component plus the analytic
52/// frame in WORLD space (as stored on the resident, world-posed solids) and in
53/// the component's LOCAL space (the solver's mate-input space).
54#[derive(Debug, Clone)]
55pub(super) struct ResolvedElement {
56    pub name: String,
57    pub component: String,
58    pub world: SelectionGeometry,
59    pub local: SelectionGeometry,
60}
61
62/// Resolve one selection ref (conventions in the module doc). Every failure is
63/// a typed status — never a panic; non-component geometry is fenced here
64/// (requirements: solids from ordinary modeling features do not participate).
65pub(super) fn resolve_element(
66    scene: &SceneMap,
67    name: &str,
68) -> Result<ResolvedElement, ConstraintFailure> {
69    let record = scene.owning_component(name).ok_or_else(|| {
70        ConstraintFailure::invalid(format!(
71            "selection '{name}' does not belong to an assembly component — only component geometry participates in constraints"
72        ))
73    })?;
74    let world = resolve_world_geometry(scene, name, record)?;
75    let inverse = record.transform.rigid_inverse().map_err(|error| {
76        ConstraintFailure::new(
77            "error",
78            format!("component '{}': non-rigid pose: {error}", record.id),
79        )
80    })?;
81    let local = world.transformed(&inverse).map_err(|error| {
82        ConstraintFailure::new("error", format!("selection '{name}': {error}"))
83    })?;
84    Ok(ResolvedElement {
85        name: name.to_string(),
86        component: record.id.clone(),
87        world,
88        local,
89    })
90}
91
92fn resolve_world_geometry(
93    scene: &SceneMap,
94    name: &str,
95    record: &ComponentRecord,
96) -> Result<SelectionGeometry, ConstraintFailure> {
97    // Vertex ref: `{solid}@x,y,z`, component-local position.
98    if let Some((solid_name, coords)) = name.split_once('@') {
99        let handle = scene.resolve_solid(solid_name).ok_or_else(|| {
100            ConstraintFailure::invalid(format!("vertex ref '{name}': unknown solid '{solid_name}'"))
101        })?;
102        let local = parse_triple(coords).ok_or_else(|| {
103            ConstraintFailure::invalid(format!(
104                "vertex ref '{name}': position must be 'x,y,z' numbers"
105            ))
106        })?;
107        let world_query = record.transform.point(local);
108        return crate::with_registered_solid_str(handle, |solid| {
109            Ok(resolve_vertex_selection(solid, world_query))
110        })
111        .map_err(ConstraintFailure::invalid)?
112        .map_err(|error| ConstraintFailure::new(error.status(), error.to_string()));
113    }
114
115    // Whole-component ref: the bare id, or a nested `ACOMP3:ACOMP1` chain
116    // (representative point over the OUTER record's members under that
117    // prefix). `owning_component` already proved the outermost segment.
118    let (_, local_name) = crate::split_component_namespace(name);
119    if crate::is_component_reference(local_name) {
120        let prefix = format!("{name}:");
121        let members: Vec<(String, u32)> = scene
122            .component_solids(&record.id)
123            .into_iter()
124            .filter(|(member, _)| record.id == name || member.starts_with(&prefix))
125            .collect();
126        if members.is_empty() {
127            return Err(ConstraintFailure::invalid(format!(
128                "component ref '{name}' has no member solids"
129            )));
130        }
131        return component_point(&members);
132    }
133
134    // Named topology: faces first, then edges (the deterministic-naming scheme
135    // never collides across the kinds).
136    if let Some(face) = scene.resolve_face(name) {
137        return crate::with_registered_solid_str(face.handle, |solid| {
138            Ok(resolve_face_selection(solid, face.face_id))
139        })
140        .map_err(ConstraintFailure::invalid)?
141        .map_err(|error| ConstraintFailure::new(error.status(), error.to_string()));
142    }
143    if let Some(edge) = scene.resolve_edge(name) {
144        return crate::with_registered_solid_str(edge.handle, |solid| {
145            Ok(resolve_edge_selection(solid, edge.edge_id))
146        })
147        .map_err(ConstraintFailure::invalid)?
148        .map_err(|error| ConstraintFailure::new(error.status(), error.to_string()));
149    }
150    // A member-solid name (rare — the app selects components, not solids):
151    // resolve like a whole-component anchor over that one solid.
152    if let Some(handle) = scene.resolve_solid(name) {
153        return component_point(&[(name.to_string(), handle)]);
154    }
155    // Last resort: an unregistered per-solid name (should not happen for an
156    // intact scene) — search the component's members by exact name.
157    for (_member, handle) in scene.component_solids(&record.id) {
158        let found = crate::with_registered_solid_str(handle, |solid| {
159            Ok(resolve_named_selection(solid, name).ok())
160        })
161        .map_err(ConstraintFailure::invalid)?;
162        if let Some(geometry) = found {
163            return Ok(geometry);
164        }
165    }
166    Err(ConstraintFailure::invalid(format!(
167        "selection '{name}' not found in the scene"
168    )))
169}
170
171/// Representative anchor of a solid set: center of the aggregate AABB over
172/// topology vertices + surface control-point hulls (bounds the exact surfaces
173/// without tessellation — same construction as `resolve_component_point`,
174/// evaluated per resident member under one short borrow each).
175fn component_point(members: &[(String, u32)]) -> Result<SelectionGeometry, ConstraintFailure> {
176    let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
177    let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
178    let mut any = false;
179    for (member, handle) in members {
180        let (lo, hi, non_empty) = crate::with_registered_solid_str(*handle, |solid| {
181            let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
182            let mut hi = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
183            let mut non_empty = false;
184            let mut include = |point: Vec3| {
185                lo = Vec3::new(lo.x.min(point.x), lo.y.min(point.y), lo.z.min(point.z));
186                hi = Vec3::new(hi.x.max(point.x), hi.y.max(point.y), hi.z.max(point.z));
187                non_empty = true;
188            };
189            for vertex in &solid.vertices {
190                include(vertex.point);
191            }
192            for shell in &solid.shells {
193                for face in &shell.faces {
194                    for row in &face.surface.control_points {
195                        for control in row {
196                            include(control.point()?);
197                        }
198                    }
199                }
200            }
201            Ok((lo, hi, non_empty))
202        })
203        .map_err(|error| {
204            ConstraintFailure::invalid(format!("component member '{member}': {error}"))
205        })?;
206        if non_empty {
207            low = Vec3::new(low.x.min(lo.x), low.y.min(lo.y), low.z.min(lo.z));
208            high = Vec3::new(high.x.max(hi.x), high.y.max(hi.y), high.z.max(hi.z));
209            any = true;
210        }
211    }
212    if !any {
213        return Err(ConstraintFailure::invalid(
214            "component has no geometry to anchor",
215        ));
216    }
217    Ok(SelectionGeometry::Point {
218        position: low.add(high).scale(0.5),
219    })
220}
221
222fn parse_triple(coords: &str) -> Option<Vec3> {
223    let mut parts = coords.split(',').map(str::trim);
224    let x = parts.next()?.parse::<f64>().ok()?;
225    let y = parts.next()?.parse::<f64>().ok()?;
226    let z = parts.next()?.parse::<f64>().ok()?;
227    if parts.next().is_some() {
228        return None;
229    }
230    Some(Vec3::new(x, y, z))
231}
232
233// ===========================================================================
234// Mate construction
235// ===========================================================================
236
237/// One mapped mate: local geometry sides keyed by owning component id.
238#[derive(Debug, Clone)]
239pub(super) struct MappedMate {
240    pub body_a: String,
241    pub body_b: String,
242    pub kind: MateKind,
243}
244
245/// A constraint's whole mapping: its mates, the `inputParams` /
246/// `persistentData` write-backs to commit ON SOLVE SUCCESS (first-solve
247/// initialization), and the current measured value for the overlay label.
248#[derive(Debug, Clone, Default)]
249pub(super) struct MappedConstraint {
250    pub mates: Vec<MappedMate>,
251    pub pending_params: Vec<(String, serde_json::Value)>,
252    pub pending_persistent: Vec<(String, serde_json::Value)>,
253    /// `(value, unit)` — the measured current quantity (`"mm"` model units or
254    /// `"deg"`), when the type has a natural scalar.
255    pub measured: Option<(f64, &'static str)>,
256    /// The evaluated target (distance/angle), for the overlay label suffix.
257    pub target: Option<f64>,
258    /// The element ROLES a multi-element type inferred, as index groups into
259    /// the entry's `elements` (center: `[width pair, tab]`) — the overlay lane
260    /// draws by role, not by pick order. Empty for the pairing types.
261    pub groups: Vec<Vec<usize>>,
262    /// A per-type sentence appended to the solved status message (center
263    /// names the roles it inferred: "X centred between Y and Z"), so the
264    /// panel shows what the constraint decided from the selection.
265    pub note: Option<String>,
266}
267
268/// Map one validated, resolved constraint onto kernel mates. `resolved` is
269/// the entry's elements in entry order (the lifecycle has already checked the
270/// count against the type's range). `persistent` is the entry's live
271/// `persistentData` — the orientation-preference cache (`preferredOppose`) is
272/// captured/re-captured here immediately (it is a resolve-time cache, not a
273/// solve result).
274pub(super) fn map_constraint(
275    entry: &mut ConstraintEntry,
276    resolved: &[ResolvedElement],
277    env: &Env,
278) -> Result<MappedConstraint, ConstraintFailure> {
279    // The pairing types take exactly two; center reads the whole slice.
280    let pair = || -> Result<(&ResolvedElement, &ResolvedElement), ConstraintFailure> {
281        match resolved {
282            [a, b] => Ok((a, b)),
283            _ => Err(ConstraintFailure::invalid(format!(
284                "{} takes exactly two elements ({} given)",
285                entry.constraint_type,
286                resolved.len()
287            ))),
288        }
289    };
290    match entry.constraint_type.as_str() {
291        "coincident" => pair().and_then(|(a, b)| super::constraints::coincident::map(a, b)),
292        "touch_align" => {
293            let (a, b) = pair()?;
294            super::constraints::touch_align::map(entry, a, b)
295        }
296        "parallel" => {
297            let (a, b) = pair()?;
298            super::constraints::parallel::map(entry, a, b)
299        }
300        "distance" => {
301            let (a, b) = pair()?;
302            super::constraints::distance::map(entry, a, b, env)
303        }
304        "angle" => {
305            let (a, b) = pair()?;
306            super::constraints::angle::map(entry, a, b, env)
307        }
308        "concentric" => {
309            let (a, b) = pair()?;
310            super::constraints::concentric::map(entry, a, b)
311        }
312        "perpendicular" => pair().and_then(|(a, b)| super::constraints::perpendicular::map(a, b)),
313        "tangent" => pair().and_then(|(a, b)| super::constraints::tangent::map(a, b)),
314        "center" => super::constraints::center::map(entry, resolved),
315        other => Err(ConstraintFailure::new(
316            "error",
317            format!("Unknown constraint type: {other}"),
318        )),
319    }
320}
321
322pub(super) fn mate(a: &ResolvedElement, b: &ResolvedElement, kind: MateKind) -> MappedMate {
323    MappedMate {
324        body_a: a.component.clone(),
325        body_b: b.component.clone(),
326        kind,
327    }
328}
329
330/// The world direction a selection carries, if any (plane normal, axis/line
331/// direction, circle axis).
332pub(super) fn direction_of(geometry: &SelectionGeometry) -> Option<Vec3> {
333    match *geometry {
334        SelectionGeometry::Plane { normal, .. } => Some(normal),
335        SelectionGeometry::Axis { direction, .. } | SelectionGeometry::Line { direction, .. } => {
336            Some(direction)
337        }
338        SelectionGeometry::Circle { axis, .. } => Some(axis),
339        SelectionGeometry::Sphere { .. } | SelectionGeometry::Point { .. } => None,
340    }
341}
342
343pub(super) fn local_point(element: &ResolvedElement) -> [f64; 3] {
344    let p = element.local.representative_point();
345    [p.x, p.y, p.z]
346}
347
348pub(super) fn require_direction(element: &ResolvedElement) -> Result<(Vec3, Vec3), ConstraintFailure> {
349    let world = direction_of(&element.world).ok_or_else(|| {
350        ConstraintFailure::unsupported(format!(
351            "selection '{}' carries no direction (needs a planar face, straight edge, axis face, or circular edge)",
352            element.name
353        ))
354    })?;
355    let local = direction_of(&element.local).expect("local mirrors world kind");
356    Ok((world, local))
357}
358
359pub(super) fn require_axis(element: &ResolvedElement) -> Result<crate::MateAxis, ConstraintFailure> {
360    element.local.mate_axis().ok_or_else(|| {
361        ConstraintFailure::unsupported(format!(
362            "selection '{}' carries no axis (needs a cylindrical/conical face, circular edge, or straight edge)",
363            element.name
364        ))
365    })
366}
367
368pub(super) fn require_plane(element: &ResolvedElement) -> Result<crate::MatePlane, ConstraintFailure> {
369    element.local.mate_plane().ok_or_else(|| {
370        ConstraintFailure::unsupported(format!(
371            "selection '{}' is not a planar face",
372            element.name
373        ))
374    })
375}
376
377/// The order-independent selection-pair signature (duplicate detection AND the
378/// orientation-preference recapture key).
379pub(super) fn pair_signature(elements: &[String]) -> String {
380    let mut pair: Vec<&str> = elements.iter().map(String::as_str).collect();
381    pair.sort_unstable();
382    pair.join("\n")
383}
384
385/// `preferredOppose` orientation preference: captured from the CURRENT world
386/// directions on first resolve (so the initial solve preserves the assembled
387/// facing), re-captured when the selection signature changes, and XOR'd with
388/// the `reverse`/`opposeNormals` toggle into the mate's align sense.
389pub(super) fn effective_align(
390    entry: &mut ConstraintEntry,
391    dir_a: Vec3,
392    dir_b: Vec3,
393    reverse: bool,
394) -> MateAlign {
395    let signature = pair_signature(&entry.elements());
396    let dot = dir_a.dot(dir_b);
397    let cached = entry
398        .persistent("preferredOpposeSignature")
399        .and_then(|value| value.as_str())
400        .map(|stored| stored == signature)
401        .unwrap_or(false)
402        .then(|| entry.persistent("preferredOppose").and_then(|v| v.as_bool()))
403        .flatten();
404    let oppose = cached.unwrap_or_else(|| {
405        let oppose = dot < 0.0;
406        entry.set_persistent("preferredOppose", serde_json::Value::Bool(oppose));
407        entry.set_persistent(
408            "preferredOpposeSignature",
409            serde_json::Value::String(signature),
410        );
411        oppose
412    });
413    entry.set_persistent(
414        "lastOrientationDot",
415        serde_json::json!(dot),
416    );
417    if oppose != reverse {
418        MateAlign::AntiAligned
419    } else {
420        MateAlign::Aligned
421    }
422}
423
424
425
426
427
428/// First-solve initialization: when `flag_key` is not yet set in
429/// `persistentData`, the target ADOPTS the current measurement and both the
430/// param and the flag are queued for commit on solve success.
431#[allow(clippy::type_complexity)]
432pub(super) fn first_solve_target(
433    entry: &ConstraintEntry,
434    param_key: &str,
435    flag_key: &str,
436    configured: f64,
437    current: f64,
438) -> (
439    f64,
440    (Vec<(String, serde_json::Value)>, Vec<(String, serde_json::Value)>),
441) {
442    let initialized = entry
443        .persistent(flag_key)
444        .and_then(|value| value.as_bool())
445        .unwrap_or(false);
446    if initialized {
447        (configured, (Vec::new(), Vec::new()))
448    } else {
449        (
450            current,
451            (
452                vec![(param_key.to_string(), serde_json::json!(current))],
453                vec![(flag_key.to_string(), serde_json::Value::Bool(true))],
454            ),
455        )
456    }
457}
458
459
460
461
462
463// ===========================================================================
464// World-space measures (overlay labels + tolerance scaling)
465// ===========================================================================
466
467pub(super) fn angle_between_deg(a: Vec3, b: Vec3) -> f64 {
468    let denominator = a.length() * b.length();
469    if denominator <= 0.0 {
470        return 0.0;
471    }
472    (a.dot(b) / denominator).clamp(-1.0, 1.0).acos().to_degrees()
473}
474
475pub(super) fn point_line_distance(point: Vec3, origin: Vec3, direction: Vec3) -> f64 {
476    let offset = point.sub(origin);
477    let along = offset.dot(direction) / direction.dot(direction).max(1e-300);
478    offset.sub(direction.scale(along)).length()
479}
480
481/// Closest approach between two infinite lines (point-to-line when nearly
482/// parallel — the skew formula degenerates there).
483pub(super) fn line_line_distance(oa: Vec3, da: Vec3, ob: Vec3, db: Vec3) -> f64 {
484    let cross = da.cross(db);
485    let denominator = cross.length();
486    if denominator < 1e-9 * da.length().max(db.length()).max(1.0) {
487        return point_line_distance(ob, oa, da);
488    }
489    (ob.sub(oa).dot(cross) / denominator).abs()
490}
491
492/// The largest coordinate magnitude a constraint's resolved elements span —
493/// the model-scale estimate behind the satisfied-vs-adjusted tolerance.
494pub(super) fn elements_scale(elements: &[ResolvedElement]) -> f64 {
495    let mut scale = 1.0f64;
496    for point in elements.iter().map(|element| element.world.representative_point()) {
497        scale = scale
498            .max(point.x.abs())
499            .max(point.y.abs())
500            .max(point.z.abs());
501    }
502    scale
503}
504
505// ===========================================================================
506// Rigid-pose math (bodies + write-back)
507// ===========================================================================
508
509/// Unit quaternion `[w,x,y,z]` from the rotation rows of a rigid transform
510/// (Shepperd's method — numerically stable for every sign pattern).
511pub(super) fn matrix_to_quaternion(transform: &AffineTransform) -> [f64; 4] {
512    let m = &transform.elements;
513    let (r00, r01, r02) = (m[0], m[1], m[2]);
514    let (r10, r11, r12) = (m[4], m[5], m[6]);
515    let (r20, r21, r22) = (m[8], m[9], m[10]);
516    let trace = r00 + r11 + r22;
517    let q = if trace > 0.0 {
518        let s = (trace + 1.0).sqrt() * 2.0;
519        [s / 4.0, (r21 - r12) / s, (r02 - r20) / s, (r10 - r01) / s]
520    } else if r00 > r11 && r00 > r22 {
521        let s = (1.0 + r00 - r11 - r22).sqrt() * 2.0;
522        [(r21 - r12) / s, s / 4.0, (r01 + r10) / s, (r02 + r20) / s]
523    } else if r11 > r22 {
524        let s = (1.0 + r11 - r00 - r22).sqrt() * 2.0;
525        [(r02 - r20) / s, (r01 + r10) / s, s / 4.0, (r12 + r21) / s]
526    } else {
527        let s = (1.0 + r22 - r00 - r11).sqrt() * 2.0;
528        [(r10 - r01) / s, (r02 + r20) / s, (r12 + r21) / s, s / 4.0]
529    };
530    normalize_quaternion(q)
531}
532
533pub(super) fn normalize_quaternion(q: [f64; 4]) -> [f64; 4] {
534    let norm = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt();
535    if norm <= 0.0 || !norm.is_finite() {
536        return [1.0, 0.0, 0.0, 0.0];
537    }
538    [q[0] / norm, q[1] / norm, q[2] / norm, q[3] / norm]
539}
540
541/// Rigid transform from a solver pose. The quaternion is RENORMALIZED first —
542/// the write-back contract (`component.rs` rejects non-rigid at 1e-8; a unit
543/// quaternion's matrix is orthonormal to machine precision).
544pub(super) fn pose_to_transform(
545    rotation: [f64; 4],
546    translation: [f64; 3],
547) -> Result<AffineTransform, String> {
548    let [w, x, y, z] = normalize_quaternion(rotation);
549    AffineTransform::new([
550        1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - w * z), 2.0 * (x * z + w * y), translation[0],
551        2.0 * (x * y + w * z), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - w * x), translation[1],
552        2.0 * (x * z - w * y), 2.0 * (y * z + w * x), 1.0 - 2.0 * (x * x + y * y), translation[2],
553        0.0, 0.0, 0.0, 1.0,
554    ])
555}
556
557/// The generic `inputParams.transform` pose write-back JSON: `{translate,
558/// rotateEulerDeg}` with degrees in the intrinsic `XYZ` order (`R = Rx·Ry·Rz`
559/// — `features::common::compose_trs_matrix`). This is the write-side half of
560/// the ACOMP feature's reader contract (`assembly_component.rs` reads exactly
561/// `transform.translate` + `transform.rotateEulerDeg`; a mismatched key would
562/// silently zero the pose on the next history run).
563///
564/// `pub` (re-exported as `brep_kernel::transform_to_pose_params`) because it is
565/// the ONE matrix → intrinsic-XYZ-Euler-degrees decomposition in the tree — the
566/// solver write-back and the out-of-crate seams that author ACOMP poses share
567/// it rather than re-deriving the convention (and its gimbal-lock branch), which
568/// the readers on the other side (`assembly_component.rs`, `transform_bake`)
569/// would then silently disagree with.
570pub fn transform_to_pose_params(transform: &AffineTransform) -> serde_json::Value {
571    let m = &transform.elements;
572    // R = Rx(a)·Ry(b)·Rz(c) ⇒ m02 = sin b; a = atan2(−m12, m22);
573    // c = atan2(−m01, m00); gimbal lock at |sin b| → 1 pins c = 0.
574    let sb = m[2].clamp(-1.0, 1.0);
575    let (a, b, c) = if sb.abs() < 1.0 - 1e-9 {
576        (
577            (-m[6]).atan2(m[10]),
578            sb.asin(),
579            (-m[1]).atan2(m[0]),
580        )
581    } else {
582        (m[9].atan2(m[5]), sb.asin(), 0.0)
583    };
584    serde_json::json!({
585        "translate": [m[3], m[7], m[11]],
586        "rotateEulerDeg": [a.to_degrees(), b.to_degrees(), c.to_degrees()],
587    })
588}