forked from 1b-agent/opencadstudio-upstream
feat(model): kernel a82f17d — MASSPROP from B-rep, curved OFFSET/FILLET
- Pins: codec 8189f83, kernel a82f17d, graph d3558bb (all manifests and the python plugin lock). - MASSPROP reports volume, centroid, inertia, products, radii of gyration and principal moments of 3D solids and bodies from their B-rep (exact where analytic, else from a fine mesh). - OFFSET of ellipses and splines through the kernel's curve offset. - FILLET between circles, ellipses and splines through the kernel's curve-curve fillet, trimming arcs and open splines to the tangents. The kernel bump brings the DWG ACIS audit fixes: slices, booleans and press/pull on cones, slanted cylinder sections, survey coordinates, spline walls and touching parts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d0e4f4097d
commit
aa0b87108c
31 files changed
+404
-109
No files matched your search
@@ -1114,6 +1114,63 @@ impl OpenCADStudio {
|
||||
for (handle, _) in &selected {
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle) {
|
||||
use crate::entities::traits::EntityTypeOps;
|
||||
// A solid's properties come from its B-rep: exact
|
||||
// where the kernel knows the shape, a fine mesh
|
||||
// otherwise — not the display mesh.
|
||||
let acis = match entity {
|
||||
codec::EntityType::Solid3D(solid) => Some(&solid.acis_data),
|
||||
codec::EntityType::Body(body) => Some(&body.acis_data),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(acis) = acis {
|
||||
let properties = crate::scene::convert::solid3d_tess::kernel_acis_body(acis)
|
||||
.and_then(|body| kernel::brep::mass_properties(&body));
|
||||
let Some(p) = properties else {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"MASSPROP: {} has no closed body to measure.",
|
||||
crate::entities::names::dxf_name(entity),
|
||||
).as_ref());
|
||||
continue;
|
||||
};
|
||||
let [x, y, z] = p.centroid;
|
||||
let [ix, iy, iz] = p.moment_of_inertia;
|
||||
let [pxy, pyz, pzx] = p.product_of_inertia;
|
||||
let [rx, ry, rz] = p.radii_of_gyration;
|
||||
let [i1, i2, i3] = p.principal_moments;
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"{} Volume={:.4} Centroid=({:.4},{:.4},{:.4})",
|
||||
crate::entities::names::dxf_name(entity),
|
||||
p.volume,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
).as_ref());
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"Moments of inertia X={:.4} Y={:.4} Z={:.4}",
|
||||
ix,
|
||||
iy,
|
||||
iz,
|
||||
).as_ref());
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"Products of inertia XY={:.4} YZ={:.4} ZX={:.4}",
|
||||
pxy,
|
||||
pyz,
|
||||
pzx,
|
||||
).as_ref());
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"Radii of gyration X={:.4} Y={:.4} Z={:.4}",
|
||||
rx,
|
||||
ry,
|
||||
rz,
|
||||
).as_ref());
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"Principal moments about centroid I={:.4} J={:.4} K={:.4}",
|
||||
i1,
|
||||
i2,
|
||||
i3,
|
||||
).as_ref());
|
||||
continue;
|
||||
}
|
||||
if let Some(props) = entity.mass_props() {
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"{} Area={:.4} Perimeter={:.4} Centroid=({:.4},{:.4})",
|
||||
|
||||
@@ -1933,6 +1933,12 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
|
||||
"M density" => Some(("properties", "m-density")),
|
||||
"M vertex count" => Some(("properties", "m-vertex-count")),
|
||||
"MASSPROP: no entities selected. Select entities first." => Some(("inquiry", "massprop-no-entities-selected-select-entities")),
|
||||
"MASSPROP: {} has no closed body to measure." => Some(("inquiry", "massprop-has-no-closed-body-to-measure")),
|
||||
"{} Volume={:.4} Centroid=({:.4},{:.4},{:.4})" => Some(("inquiry", "volume-4-centroid-4-4-4")),
|
||||
"Moments of inertia X={:.4} Y={:.4} Z={:.4}" => Some(("inquiry", "moments-of-inertia-x-4-y-4-z-4")),
|
||||
"Products of inertia XY={:.4} YZ={:.4} ZX={:.4}" => Some(("inquiry", "products-of-inertia-xy-4-yz-4-zx-4")),
|
||||
"Radii of gyration X={:.4} Y={:.4} Z={:.4}" => Some(("inquiry", "radii-of-gyration-x-4-y-4-z-4")),
|
||||
"Principal moments about centroid I={:.4} J={:.4} K={:.4}" => Some(("inquiry", "principal-moments-about-centroid-i-4-j-4-k-4")),
|
||||
"MATCHPROP Select destination objects (Enter to finish):" => Some(("properties", "matchprop-select-destination-objects-enter-to")),
|
||||
"MATCHPROP Select source object:" => Some(("properties", "matchprop-select-source-object")),
|
||||
"MEASURE Select object to measure:" => Some(("inquiry", "measure-select-object-to-measure")),
|
||||
|
||||
@@ -769,6 +769,9 @@ enum FilletEntity {
|
||||
handle: Handle,
|
||||
seg_idx: usize,
|
||||
},
|
||||
/// A circle, ellipse or spline: filleted through the kernel's general
|
||||
/// construction, and trimmed only when it has ends to trim.
|
||||
Curve(EntityType),
|
||||
}
|
||||
|
||||
impl FilletEntity {
|
||||
@@ -776,6 +779,9 @@ impl FilletEntity {
|
||||
match e {
|
||||
EntityType::Line(l) => Some(Self::Line(l.clone())),
|
||||
EntityType::Arc(a) => Some(Self::Arc(a.clone())),
|
||||
EntityType::Circle(_) | EntityType::Ellipse(_) | EntityType::Spline(_) => {
|
||||
crate::entities::curve::entity_curve_xy(e).map(|_| Self::Curve(e.clone()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -794,6 +800,7 @@ impl FilletEntity {
|
||||
Self::Line(l) => EntityType::Line(l.clone()),
|
||||
Self::Arc(a) => EntityType::Arc(a.clone()),
|
||||
Self::LwPoly { poly, .. } => EntityType::LwPolyline(poly.clone()),
|
||||
Self::Curve(entity) => entity.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -802,6 +809,20 @@ impl FilletEntity {
|
||||
Self::Line(l) => l.start.z,
|
||||
Self::Arc(a) => a.center.z,
|
||||
Self::LwPoly { poly, .. } => poly.elevation,
|
||||
Self::Curve(EntityType::Circle(c)) => c.center.z,
|
||||
Self::Curve(EntityType::Ellipse(e)) => e.center.z,
|
||||
Self::Curve(EntityType::Spline(s)) => s.control_points.first().map_or(0.0, |p| p.z),
|
||||
Self::Curve(_) => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape as a kernel curve in plan.
|
||||
fn kernel_curve(&self) -> Option<KernelCurve> {
|
||||
match self {
|
||||
Self::Line(l) => Some(line_curve(l)),
|
||||
Self::Arc(a) => Some(arc_curve(a)),
|
||||
Self::LwPoly { poly, seg_idx, .. } => Some(line_curve(&lwpoly_seg_as_line(poly, *seg_idx))),
|
||||
Self::Curve(entity) => crate::entities::curve::entity_curve_xy(entity),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -825,6 +846,9 @@ fn compute_fillet_entities(
|
||||
if picks_arc_segment(e1) || picks_arc_segment(e2) {
|
||||
return None;
|
||||
}
|
||||
if matches!(e1, FilletEntity::Curve(_)) || matches!(e2, FilletEntity::Curve(_)) {
|
||||
return fillet_curves(e1, click1, e2, click2, radius, z);
|
||||
}
|
||||
|
||||
match (e1, e2) {
|
||||
// ── Line × Line ───────────────────────────────────────────────────
|
||||
@@ -1011,6 +1035,8 @@ fn compute_fillet_entities(
|
||||
None
|
||||
}
|
||||
}
|
||||
// Answered by `fillet_curves` above.
|
||||
(FilletEntity::Curve(_), _) | (_, FilletEntity::Curve(_)) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1268,6 +1294,116 @@ fn fillet_arc_arc(
|
||||
None
|
||||
}
|
||||
|
||||
/// FILLET where one side is a circle, ellipse or spline. The kernel finds the
|
||||
/// tangent arcs; each side is then cut back to its tangent point, keeping the
|
||||
/// part that was picked. A closed curve has no end to cut back and is left
|
||||
/// whole.
|
||||
fn fillet_curves(
|
||||
e1: &FilletEntity,
|
||||
click1: [f64; 2],
|
||||
e2: &FilletEntity,
|
||||
click2: [f64; 2],
|
||||
radius: f64,
|
||||
z: f64,
|
||||
) -> Option<(EntityType, EntityType, Option<EntityType>)> {
|
||||
if radius < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = (e1.kernel_curve()?, e2.kernel_curve()?);
|
||||
let common = match e1 {
|
||||
FilletEntity::Line(l) => l.common.clone(),
|
||||
FilletEntity::Arc(a) => a.common.clone(),
|
||||
FilletEntity::LwPoly { poly, .. } => poly.common.clone(),
|
||||
FilletEntity::Curve(entity) => entity.common().clone(),
|
||||
};
|
||||
for fillet in ranked_fillets(&a, click1, &b, click2, radius) {
|
||||
let (Some(first), Some(second)) = (
|
||||
trim_to_tangent(e1, &a, fillet.tangent1, click1),
|
||||
trim_to_tangent(e2, &b, fillet.tangent2, click2),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
return Some((
|
||||
first,
|
||||
second,
|
||||
Some(EntityType::Arc(fillet_entity(&fillet, radius, z, &common))),
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One side of a fillet cut back to where the arc touches it, the picked part
|
||||
/// kept. `None` when the touch is off a bounded curve's drawn part — only a
|
||||
/// line may be extended to meet the arc.
|
||||
fn trim_to_tangent(
|
||||
entity: &FilletEntity,
|
||||
curve: &KernelCurve,
|
||||
touch: [f64; 2],
|
||||
click: [f64; 2],
|
||||
) -> Option<EntityType> {
|
||||
match entity {
|
||||
FilletEntity::Line(l) => trim_line_to_point(l, touch, click).map(EntityType::Line),
|
||||
FilletEntity::LwPoly { poly, seg_idx, .. } => {
|
||||
let line = lwpoly_seg_as_line(poly, *seg_idx);
|
||||
let trimmed = trim_line_to_point(&line, touch, click)?;
|
||||
Some(EntityType::LwPolyline(rebuild_poly_from_trimmed_line(poly, *seg_idx, &line, &trimmed)))
|
||||
}
|
||||
FilletEntity::Arc(arc) => {
|
||||
let (centre, _, start, end, _) = arc_geom(arc);
|
||||
let at = arc_angle_at(centre, touch);
|
||||
let clamped = clamp_angle_to_arc(at, start, end);
|
||||
if (norm_angle(at) - norm_angle(clamped)).abs() > 0.01 {
|
||||
return None;
|
||||
}
|
||||
let picked = clamp_angle_to_arc(arc_angle_at(centre, click), start, end);
|
||||
Some(EntityType::Arc(
|
||||
if (clamped - start).rem_euclid(TAU) <= (picked - start).rem_euclid(TAU) {
|
||||
trim_arc(arc, clamped, end)
|
||||
} else {
|
||||
trim_arc(arc, start, clamped)
|
||||
},
|
||||
))
|
||||
}
|
||||
FilletEntity::Curve(source) => {
|
||||
let at = curve.parameter_at(touch);
|
||||
let on = curve.point_at(at);
|
||||
let scale = 1e-6 * (1.0 + touch[0].abs().max(touch[1].abs()));
|
||||
if (on[0] - touch[0]).hypot(on[1] - touch[1]) > scale.max(1e-6 * curve.length()) {
|
||||
return None;
|
||||
}
|
||||
if curve.is_closed() {
|
||||
return Some(source.clone());
|
||||
}
|
||||
let keep_end = curve.parameter_at(click) >= at;
|
||||
match source {
|
||||
// Its plan image runs the entity's own parameter only when
|
||||
// it faces up; a mirrored one is left alone.
|
||||
EntityType::Ellipse(ellipse) if ellipse.normal.z > 0.0 => {
|
||||
let mut trimmed = ellipse.clone();
|
||||
trimmed.common.handle = Handle::NULL;
|
||||
let sweep = {
|
||||
let raw = ellipse.end_parameter - ellipse.start_parameter;
|
||||
if raw <= 0.0 { raw + TAU } else { raw }
|
||||
};
|
||||
let cut = ellipse.start_parameter + at * sweep;
|
||||
if keep_end {
|
||||
trimmed.start_parameter = cut;
|
||||
} else {
|
||||
trimmed.end_parameter = cut;
|
||||
}
|
||||
Some(EntityType::Ellipse(trimmed))
|
||||
}
|
||||
EntityType::Spline(spline) => {
|
||||
let (low, high) = super::spline_ops::spline_range(spline)?;
|
||||
let (head, tail) = super::spline_ops::spline_cut(spline, low + at * (high - low))?;
|
||||
Some(EntityType::Spline(if keep_end { tail } else { head }))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim a line endpoint nearest to the intersection point, keeping the click side.
|
||||
fn trim_line_to_point(line: &LineEnt, isect: [f64; 2], click: [f64; 2]) -> Option<LineEnt> {
|
||||
let mut l = line.clone();
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
// offset at once on the cursor's side; a negative one, on the other side.
|
||||
|
||||
use crate::entities::curve::{entity_curve, lwpolyline_world_xy};
|
||||
use crate::modules::draw::modify::spline_ops::spline_sample_xy;
|
||||
use codec::entities::LwVertex;
|
||||
use codec::entities::{
|
||||
Arc as ArcEnt, Circle as CircleEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline,
|
||||
@@ -24,7 +23,6 @@ use codec::entities::{
|
||||
use codec::{EntityType, Handle};
|
||||
// Polyline offsetting, and the angle normalisation that goes with it, come
|
||||
// from the kernel; only the entity conversion stays here.
|
||||
use kernel::geom2d::nurbs::clamped_uniform_knots;
|
||||
use kernel::geom2d::{
|
||||
offset_polyline, Polyline as KernelPolyline, PolylineVertex as KernelVertex,
|
||||
};
|
||||
@@ -205,111 +203,77 @@ fn offset_lwpolyline(p: &LwPolyline, dist: f64, side_pt: Vec3) -> Option<EntityT
|
||||
offset_lwpolylines(p, dist, side_pt).into_iter().next()
|
||||
}
|
||||
|
||||
// ── Ellipse offset ─────────────────────────────────────────────────────────
|
||||
// ── Ellipse and spline offset ──────────────────────────────────────────────
|
||||
//
|
||||
// A true offset of an ellipse is a Lamé curve, not an ellipse. As an
|
||||
// acceptable CAD approximation we scale both semi-axes uniformly and keep
|
||||
// the same orientation, center and parameter range. The sign of the offset
|
||||
// is determined by whether side_pt is inside or outside the ellipse.
|
||||
// Neither has a parallel of its own kind — an ellipse's is not an ellipse —
|
||||
// so both come back as a spline fitted through true offset points, within a
|
||||
// millionth of the curve's size, by the kernel. The work happens in the
|
||||
// curve's own plane, so a tilted or extruded one offsets in place.
|
||||
|
||||
fn offset_ellipse(e: &EllipseEnt, dist: f64, side_pt: Vec3) -> Option<EntityType> {
|
||||
let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt();
|
||||
if a < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let b = a * e.minor_axis_ratio;
|
||||
let nx = e.major_axis.x / a;
|
||||
let ny = e.major_axis.y / a;
|
||||
// Project side_pt onto ellipse local frame and test inside/outside.
|
||||
let rx = side_pt.x as f64 - e.center.x;
|
||||
let ry = side_pt.y as f64 - e.center.y;
|
||||
let xl = rx * nx + ry * ny;
|
||||
let yl = -rx * ny + ry * nx;
|
||||
let inside = (xl / a).powi(2) + (yl / b).powi(2) < 1.0;
|
||||
let sign = if inside { -1.0 } else { 1.0 };
|
||||
|
||||
let new_a = a + sign * dist;
|
||||
let new_b = b + sign * dist;
|
||||
if new_a <= 1e-9 || new_b <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut new_e = e.clone();
|
||||
new_e.common.handle = Handle::NULL;
|
||||
// Scale the major_axis vector proportionally.
|
||||
let scale = new_a / a;
|
||||
new_e.major_axis.x *= scale;
|
||||
new_e.major_axis.y *= scale;
|
||||
new_e.major_axis.z *= scale;
|
||||
new_e.minor_axis_ratio = new_b / new_a;
|
||||
Some(EntityType::Ellipse(new_e))
|
||||
let source = EntityType::Ellipse(e.clone());
|
||||
let mut template = SplineEnt::new();
|
||||
template.common = e.common.clone();
|
||||
offset_curved(&source, template, dist, side_pt)
|
||||
}
|
||||
|
||||
// ── Spline offset ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Strategy: sample the spline into N points, offset each sample point by
|
||||
// `dist` along the local perpendicular (based on the finite-difference
|
||||
// tangent), then fit a new spline through the offset points.
|
||||
|
||||
fn offset_spline(spl: &SplineEnt, dist: f64, side_pt: Vec3) -> Option<EntityType> {
|
||||
let (ts_knot, pts) = spline_sample_xy(spl, 64);
|
||||
let n = pts.len();
|
||||
if n < 2 {
|
||||
offset_curved(&EntityType::Spline(spl.clone()), spl.clone(), dist, side_pt)
|
||||
}
|
||||
|
||||
fn offset_curved(
|
||||
source: &EntityType,
|
||||
mut template: SplineEnt,
|
||||
dist: f64,
|
||||
side_pt: Vec3,
|
||||
) -> Option<EntityType> {
|
||||
let planar = entity_curve(source)?;
|
||||
let side = planar
|
||||
.plane
|
||||
.project([side_pt.x as f64, side_pt.y as f64, side_pt.z as f64])?;
|
||||
// Left of the curve's direction of travel is the kernel's positive side.
|
||||
let curve = &planar.curve;
|
||||
let at = curve.parameter_at(side);
|
||||
let step = 1e-6;
|
||||
let (before, after) = if curve.is_closed() {
|
||||
(curve.point_at((at - step).rem_euclid(1.0)), curve.point_at((at + step).rem_euclid(1.0)))
|
||||
} else {
|
||||
(curve.point_at((at - step).max(0.0)), curve.point_at((at + step).min(1.0)))
|
||||
};
|
||||
let foot = curve.point_at(at);
|
||||
let along = [after[0] - before[0], after[1] - before[1]];
|
||||
let toward = [side[0] - foot[0], side[1] - foot[1]];
|
||||
let left = along[0] * toward[1] - along[1] * toward[0] > 0.0;
|
||||
let signed = if left { dist.abs() } else { -dist.abs() };
|
||||
let tolerance = (curve.length() * 1e-6).max(1e-9);
|
||||
let kernel::geom2d::Curve::Nurbs(parallel) =
|
||||
kernel::geom2d::offset_curve(curve, signed, tolerance)?
|
||||
else {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Determine offset sign from the first non-degenerate tangent.
|
||||
let sign: f64 = (0..n - 1).find_map(|i| {
|
||||
let dx = pts[i + 1][0] - pts[i][0];
|
||||
let dy = pts[i + 1][1] - pts[i][1];
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if len < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let vx = side_pt.x as f64 - pts[i][0];
|
||||
let vy = side_pt.y as f64 - pts[i][1];
|
||||
let cross = dx * vy - dy * vx;
|
||||
Some(if cross >= 0.0 { 1.0 } else { -1.0 })
|
||||
})?;
|
||||
|
||||
// Offset each sample point along the local normal.
|
||||
let offset_pts: Vec<codec::types::Vector3> = pts
|
||||
};
|
||||
template.common.handle = Handle::NULL;
|
||||
template.degree = parallel.degree() as i32;
|
||||
template.knots = parallel.knots().to_vec();
|
||||
template.control_points = parallel
|
||||
.control_points()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
// Tangent via central / forward / backward difference.
|
||||
let (dx, dy) = if i == 0 {
|
||||
let d = [pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]];
|
||||
(d[0], d[1])
|
||||
} else if i == n - 1 {
|
||||
let d = [pts[n - 1][0] - pts[n - 2][0], pts[n - 1][1] - pts[n - 2][1]];
|
||||
(d[0], d[1])
|
||||
} else {
|
||||
(
|
||||
(pts[i + 1][0] - pts[i - 1][0]) * 0.5,
|
||||
(pts[i + 1][1] - pts[i - 1][1]) * 0.5,
|
||||
)
|
||||
};
|
||||
let len = (dx * dx + dy * dy).sqrt().max(1e-12);
|
||||
let nx = -dy / len; // left perpendicular
|
||||
let ny = dx / len;
|
||||
let z = spl.control_points.first().map(|v| v.z).unwrap_or(0.0);
|
||||
codec::types::Vector3::new(p[0] + sign * nx * dist, p[1] + sign * ny * dist, z)
|
||||
.map(|uv| {
|
||||
let [x, y, z] = planar.plane.point_at(*uv);
|
||||
codec::types::Vector3::new(x, y, z)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let _ = ts_knot;
|
||||
// Build a new spline from the offset control points (treat sample pts as fit pts → ctrl pts).
|
||||
let degree = spl.degree.max(1) as usize;
|
||||
let new_ctrl: Vec<codec::types::Vector3> = offset_pts;
|
||||
let n_ctrl = new_ctrl.len();
|
||||
let mut new_spl = spl.clone();
|
||||
new_spl.common.handle = Handle::NULL;
|
||||
new_spl.control_points = new_ctrl;
|
||||
new_spl.knots = clamped_uniform_knots(degree, n_ctrl);
|
||||
new_spl.fit_points.clear();
|
||||
new_spl.weights.clear();
|
||||
Some(EntityType::Spline(new_spl))
|
||||
template.weights = if parallel.is_rational() { parallel.weights().to_vec() } else { Vec::new() };
|
||||
template.flags.rational = parallel.is_rational();
|
||||
template.flags.periodic = false;
|
||||
template.flags.closed = curve.is_closed();
|
||||
if let Some([x, y, z]) = planar.plane.normal() {
|
||||
template.normal = codec::types::Vector3::new(x, y, z);
|
||||
}
|
||||
// A parallel is no longer the curve through the source's fit points.
|
||||
template.fit_points.clear();
|
||||
template.begin_tangent = codec::types::Vector3::new(0.0, 0.0, 0.0);
|
||||
template.end_tangent = codec::types::Vector3::new(0.0, 0.0, 0.0);
|
||||
Some(EntityType::Spline(template))
|
||||
}
|
||||
|
||||
// ── Dispatch ───────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user