@metamesh/modeling 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,6 +28,7 @@ export { cumulativePathLengths, pathLength } from "./paths/path.js";
28
28
  export { distributeAlongPath, roundClosedPolylinePath, } from "./paths/planar.js";
29
29
  export { circleLoop, rectangleLoop, regularPolygonLoop, starLoop, } from "./profiles/profiles.js";
30
30
  export { sampleOpenProfile, } from "./profiles/open-profile.js";
31
+ export { sampleCrossSection, } from "./profiles/cross-section.js";
31
32
  export { ellipseLoop } from "./profiles/shapes.js";
32
33
  export { rectangleProfile, } from "./profiles/rectangle.js";
33
34
  export { loopContainsPoint, normalizeLoop2, normalizePolygon2, normalizeRegion2, regionArea, regionBounds, signedAreaLoop2, } from "./regions.js";
@@ -0,0 +1,102 @@
1
+ import { sampleCurve2 } from "../curves/sampling.js";
2
+ import { GeometryError } from "../errors.js";
3
+ import { normalizeLoop2 } from "../regions.js";
4
+ import { resolveGeometrySamplingLimits } from "../sampling-limits.js";
5
+ import { requireProfileVertexCount } from "./validation.js";
6
+ const OPERATION = "sampleCrossSection";
7
+ function invalid(message) {
8
+ throw new GeometryError("INVALID_ARGUMENT", message, { operation: OPERATION });
9
+ }
10
+ /**
11
+ * Samples 2–32 connected line/cubic segments into a simple, nonzero-area, immutable CCW XY loop.
12
+ * Physical scaling precedes adaptive curve sampling; the final end must repeat start exactly.
13
+ * Shared anchors and the closing seam are emitted once. Winding normalization may change the
14
+ * starting vertex. The sampled loop is checked for self-intersection and degeneracy by
15
+ * `normalizeLoop2`; this is a single contour without holes. No editor modes or normalized
16
+ * coordinate range are required. No native engine, SDK, or ambient resolution is used.
17
+ *
18
+ * @param curve - Closed contour in millimetres unless positive scales convert its coordinates.
19
+ * @param options - Physical resolution and unique vertex budget; defaults to 4096 vertices.
20
+ * @returns Frozen counterclockwise loop in millimetres, without a repeated seam endpoint.
21
+ * @throws {@link GeometryError} for invalid data, topology, scale, or exceeded sampling budgets.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * const section = sampleCrossSection({
26
+ * start: { x: 0, y: 0 },
27
+ * segments: [
28
+ * { type: "line", end: { x: 1, y: 0 } },
29
+ * { type: "line", end: { x: 0.5, y: 1 } },
30
+ * { type: "line", end: { x: 0, y: 0 } },
31
+ * ],
32
+ * }, { widthScale: 20, heightScale: 12 });
33
+ * ```
34
+ */
35
+ export function sampleCrossSection(curve, options = {}) {
36
+ const widthScale = options.widthScale ?? 1;
37
+ const heightScale = options.heightScale ?? 1;
38
+ if (![widthScale, heightScale].every((scale) => Number.isFinite(scale) && scale > 0))
39
+ invalid("Cross-section scales must be finite and positive.");
40
+ const limits = resolveGeometrySamplingLimits(options.limits);
41
+ const maximumVertices = options.maximumVertices ?? Math.min(4096, limits.maxProfilePoints);
42
+ requireProfileVertexCount(maximumVertices, 3, OPERATION, limits);
43
+ const segmentInput = curve?.segments;
44
+ if (!curve ||
45
+ !Array.isArray(segmentInput) ||
46
+ curve.segments.length < 2 ||
47
+ curve.segments.length > 32)
48
+ invalid("A cross section must contain 2–32 segments.");
49
+ const physical = (point) => {
50
+ if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y))
51
+ invalid("Cross-section coordinates must be finite.");
52
+ const result = [point.x * widthScale, point.y * heightScale];
53
+ if (!result.every(Number.isFinite))
54
+ invalid("Scaled cross-section coordinates must be finite.");
55
+ return result;
56
+ };
57
+ const first = physical(curve.start);
58
+ const final = curve.segments.at(-1)?.end;
59
+ if (!final || final.x !== curve.start.x || final.y !== curve.start.y)
60
+ invalid("The final cross-section endpoint must match the start exactly.");
61
+ const output = [first];
62
+ let start = first;
63
+ for (let index = 0; index < curve.segments.length; index++) {
64
+ const segment = curve.segments[index];
65
+ if (!segment)
66
+ invalid("Cross-section segments must be lines or cubics.");
67
+ const end = physical(segment.end);
68
+ const closed = index === curve.segments.length - 1;
69
+ const remainingVertices = Math.min(limits.maxPathPoints, maximumVertices - output.length + 1 + (closed ? 1 : 0));
70
+ if (remainingVertices < 2)
71
+ throw new GeometryError("COMPLEXITY_LIMIT", "Cross-section sampling exceeds its vertex budget.", {
72
+ operation: OPERATION,
73
+ details: { maximumVertices },
74
+ });
75
+ let span;
76
+ if (segment.type === "line") {
77
+ span = { kind: "line", start, end };
78
+ }
79
+ else if (segment.type === "cubic") {
80
+ span = {
81
+ kind: "cubic",
82
+ start,
83
+ end,
84
+ control1: physical(segment.control1),
85
+ control2: physical(segment.control2),
86
+ };
87
+ }
88
+ else {
89
+ invalid("Cross-section segments must be lines or cubics.");
90
+ }
91
+ const sampled = sampleCurve2(span, {
92
+ ...(options.resolution === undefined ? {} : { resolution: options.resolution }),
93
+ maximumVertices: remainingVertices,
94
+ limits,
95
+ }).points;
96
+ const endIndex = sampled.length - (closed ? 1 : 0);
97
+ for (let pointIndex = 1; pointIndex < endIndex; pointIndex++)
98
+ output.push(sampled[pointIndex]);
99
+ start = end;
100
+ }
101
+ return normalizeLoop2(output, true);
102
+ }
@@ -280,7 +280,14 @@ function canvasTransform(values, width, height, box, origin) {
280
280
  return [appliedX, 0, 0, -appliedY, offsetX, offsetY];
281
281
  }
282
282
  function isPainted(color, opacity) {
283
- return color !== "none" && color !== "transparent" && opacity > 0;
283
+ // Mirrored by the editor's core/document.ts: embedded CSS alpha also controls geometry.
284
+ const normalized = color.trim().toLowerCase();
285
+ if (normalized === "none" || normalized === "transparent" || !(opacity > 0))
286
+ return false;
287
+ if (/^#[0-9a-f]{4}(?:[0-9a-f]{4})?$/u.test(normalized))
288
+ return Number.parseInt(normalized.slice(normalized.length === 5 ? -1 : -2), 16) > 0;
289
+ const components = /^(?:rgb|hsl)a?\((.*)\)$/u.exec(normalized)?.[1]?.split(",");
290
+ return components?.length !== 4 || Number(components[3].trim().replace(/%$/u, "")) > 0;
284
291
  }
285
292
  function ellipsePoints(cx, cy, rx, ry, segments, budget) {
286
293
  if (rx <= 0 || ry <= 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamesh/modeling",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Scoped solid modeling and pure geometry foundations for Node and browsers.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {