Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/operator-backend/src/allowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ const defaultAllowed: Allowlist = {
"/revit/create-revision-cloud",
"/revit/keynotes",
"/revit/tag-elements",
"/revit/plan-family-evolution",
"/revit/apply-family-evolution",
"/revit/read-family-evolution",
"/revit/create-dimension",
"/revit/create-sheet",
"/revit/create-sheets",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using RevitBridge.Common.FamilyEvolution;
using Xunit;

namespace RevitBridge.Common.Tests
{
public sealed class FamilyEvolutionPlanTests
{
[Theory]
[InlineData(1.70, 0.0, "right")]
[InlineData(-1.70, 0.0, "left")]
[InlineData(0.0, 2.05, "front")]
[InlineData(0.0, -2.05, "back")]
public void ResolveConnectorSide_UsesNearestEquipmentEdge(double x, double y, string expected)
{
Assert.Equal(expected, FamilyEvolutionPlan.ResolveConnectorSide(x, y, 10.0 / 3.0, 4.0));
}

[Fact]
public void ResolveConnectorSide_MatchesHru403ElectricalConnectorFixture()
{
var side = FamilyEvolutionPlan.ResolveConnectorSide(1.715879265091864, -1.361548556430455, 3.33333333333333, 4.03543307086614);
Assert.Equal("right", side);
}

[Fact]
public void ResolveConnectorSide_FailsClosedAtAmbiguousCorner()
{
Assert.Throws<InvalidOperationException>(() => FamilyEvolutionPlan.ResolveConnectorSide(2.0, 2.0, 4.0, 4.0));
}

[Fact]
public void ResolveConnectorSide_FailsClosedForInteriorConnector()
{
Assert.Throws<InvalidOperationException>(() => FamilyEvolutionPlan.ResolveConnectorSide(1.0, 0.25, 4.0, 4.0));
}

[Fact]
public void BuildClearanceRectangle_RightSideStartsAtEquipmentEdgeAndExtendsThirtySixInches()
{
var segments = FamilyEvolutionPlan.BuildClearanceRectangle(44.0 / 12.0, 50.0 / 12.0, 3.0, "right");
Assert.Equal(4, segments.Count);
Assert.Equal(22.0 / 12.0, segments[0].X1, 8);
Assert.Equal(58.0 / 12.0, segments[0].X2, 8);
Assert.Equal(-25.0 / 12.0, segments[0].Y1, 8);
Assert.Equal(25.0 / 12.0, segments[2].Y1, 8);
}

[Fact]
public void ComputePlanHash_IsOrderIndependentAndDetectsDrift()
{
var a = FamilyEvolutionPlan.ComputePlanHash(new[]
{
new KeyValuePair<string, string>("instance", "1465049"),
new KeyValuePair<string, string>("width", "3.66666667")
});
var b = FamilyEvolutionPlan.ComputePlanHash(new[]
{
new KeyValuePair<string, string>("width", "3.66666667"),
new KeyValuePair<string, string>("instance", "1465049")
});
var drift = FamilyEvolutionPlan.ComputePlanHash(new[]
{
new KeyValuePair<string, string>("instance", "1465049"),
new KeyValuePair<string, string>("width", "3.75")
});
Assert.Equal(a, b);
Assert.NotEqual(a, drift);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;

namespace RevitBridge.Common.FamilyEvolution
{
public sealed class ClearanceSegment
{
public ClearanceSegment(double x1, double y1, double x2, double y2)
{
X1 = x1;
Y1 = y1;
X2 = x2;
Y2 = y2;
}

public double X1 { get; }
public double Y1 { get; }
public double X2 { get; }
public double Y2 { get; }
}

public static class FamilyEvolutionPlan
{
private const string Schema = "revit-operator-family-evolution-v1";

public static string ResolveConnectorSide(
double connectorX,
double connectorY,
double widthFt,
double depthFt,
double ambiguityToleranceFt = 1.0 / 96.0,
double maximumEdgeDistanceFt = 0.25)
{
RequirePositiveFinite(widthFt, nameof(widthFt));
RequirePositiveFinite(depthFt, nameof(depthFt));
if (!Finite(connectorX) || !Finite(connectorY))
throw new ArgumentOutOfRangeException("Connector coordinates must be finite.");
if (!Finite(ambiguityToleranceFt) || ambiguityToleranceFt < 0)
throw new ArgumentOutOfRangeException(nameof(ambiguityToleranceFt));
if (!Finite(maximumEdgeDistanceFt) || maximumEdgeDistanceFt <= 0)
throw new ArgumentOutOfRangeException(nameof(maximumEdgeDistanceFt));

var halfWidth = widthFt / 2.0;
var halfDepth = depthFt / 2.0;
var distances = new[]
{
new KeyValuePair<string, double>("right", Math.Abs(connectorX - halfWidth)),
new KeyValuePair<string, double>("left", Math.Abs(connectorX + halfWidth)),
new KeyValuePair<string, double>("front", Math.Abs(connectorY - halfDepth)),
new KeyValuePair<string, double>("back", Math.Abs(connectorY + halfDepth))
}.OrderBy(x => x.Value).ThenBy(x => x.Key, StringComparer.Ordinal).ToList();

if (distances.Count > 1 && Math.Abs(distances[1].Value - distances[0].Value) <= ambiguityToleranceFt)
throw new InvalidOperationException("Electrical connector is ambiguous between two family sides.");
if (distances[0].Value > maximumEdgeDistanceFt)
throw new InvalidOperationException("Electrical connector is not close enough to a family side to infer a clearance side safely.");
return distances[0].Key;
}

public static IReadOnlyList<ClearanceSegment> BuildClearanceRectangle(
double widthFt,
double depthFt,
double offsetFt,
string side)
{
RequirePositiveFinite(widthFt, nameof(widthFt));
RequirePositiveFinite(depthFt, nameof(depthFt));
RequirePositiveFinite(offsetFt, nameof(offsetFt));
side = NormalizeSide(side);

var halfWidth = widthFt / 2.0;
var halfDepth = depthFt / 2.0;
double minX;
double maxX;
double minY;
double maxY;
switch (side)
{
case "right":
minX = halfWidth;
maxX = halfWidth + offsetFt;
minY = -halfDepth;
maxY = halfDepth;
break;
case "left":
minX = -halfWidth - offsetFt;
maxX = -halfWidth;
minY = -halfDepth;
maxY = halfDepth;
break;
case "front":
minX = -halfWidth;
maxX = halfWidth;
minY = halfDepth;
maxY = halfDepth + offsetFt;
break;
case "back":
minX = -halfWidth;
maxX = halfWidth;
minY = -halfDepth - offsetFt;
maxY = -halfDepth;
break;
default:
throw new InvalidOperationException("Unsupported clearance side.");
}

return new[]
{
new ClearanceSegment(minX, minY, maxX, minY),
new ClearanceSegment(maxX, minY, maxX, maxY),
new ClearanceSegment(maxX, maxY, minX, maxY),
new ClearanceSegment(minX, maxY, minX, minY)
};
}

public static string ComputePlanHash(IEnumerable<KeyValuePair<string, string>> fields)
{
if (fields == null) throw new ArgumentNullException(nameof(fields));
var rows = fields.Select(field =>
{
var key = (field.Key ?? string.Empty).Trim();
var value = field.Value ?? string.Empty;
if (key.Length == 0 || key.IndexOf('\r') >= 0 || key.IndexOf('\n') >= 0)
throw new ArgumentException("Plan hash keys must be nonblank single-line strings.", nameof(fields));
if (value.IndexOf('\r') >= 0 || value.IndexOf('\n') >= 0)
throw new ArgumentException("Plan hash values must be single-line strings.", nameof(fields));
return key + "=" + value;
}).OrderBy(x => x, StringComparer.Ordinal).ToList();
if (rows.Count == 0) throw new ArgumentException("At least one plan field is required.", nameof(fields));

using (var sha = SHA256.Create())
{
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(Schema + "\n" + string.Join("\n", rows)));
return string.Concat(bytes.Select(x => x.ToString("x2", CultureInfo.InvariantCulture)));
}
}

public static string CanonicalNumber(double value)
{
if (!Finite(value)) throw new ArgumentOutOfRangeException(nameof(value));
return Math.Round(value, 8, MidpointRounding.AwayFromZero).ToString("0.########", CultureInfo.InvariantCulture);
}

public static string NormalizeSide(string side)
{
var normalized = (side ?? string.Empty).Trim().ToLowerInvariant().Replace('-', '_').Replace(' ', '_');
if (normalized == "top") normalized = "front";
if (normalized == "bottom") normalized = "back";
if (normalized != "right" && normalized != "left" && normalized != "front" && normalized != "back")
throw new ArgumentException("clearance.side must be right, left, front, back, or power_connection.", nameof(side));
return normalized;
}

private static void RequirePositiveFinite(double value, string name)
{
if (!Finite(value) || value <= 0) throw new ArgumentOutOfRangeException(name);
}

private static bool Finite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
}
}
Loading
Loading