@metamesh/modeling 0.8.0 → 0.10.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.
@@ -588,7 +588,19 @@ export declare type LinearPatternOptions = {
588
588
  /** Host-bound loft operation. Valid only during a synchronous build. */
589
589
  export declare const loft: ModelingContext["loft"];
590
590
 
591
- /** Deterministic ordered edge subdivision or bounded geometric vertex association. */
591
+ /**
592
+ * Associates ordered loft contours without inferring component identities or repairing topology.
593
+ *
594
+ * - `direct` preserves seams and subdivides edges to a common count without dropping vertices.
595
+ * - `reindex` applies direct subdivision, then selects the next contour's cyclic phase with the
596
+ * least total world-space connection length.
597
+ * - `fast-distance` preserves supplied seams and minimizes connection length over ordered closed
598
+ * vertex associations, repeating vertices for triangular fans.
599
+ * - `distance` runs the same ordered optimization over every cyclic phase of the next contour.
600
+ *
601
+ * Components and holes are paired by their existing order. Use identified profiles when semantic
602
+ * correspondence must remain authoritative.
603
+ */
592
604
  export declare type LoftCorrespondence = "direct" | "reindex" | "fast-distance" | "distance";
593
605
 
594
606
  /**
@@ -27,7 +27,8 @@ export { sampleHelix } from "./paths/helix.js";
27
27
  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
- export { sampleOpenProfile, } from "./profiles/open-profile.js";
30
+ export { sampleSideProfile, } from "./profiles/side-profile.js";
31
+ export { sampleContour, } from "./profiles/contour.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 = "sampleContour";
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 = sampleContour({
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 sampleContour(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
+ }
@@ -2,7 +2,7 @@ import { GeometryError } from "../errors.js";
2
2
  import { resolveGeometryResolution } from "../resolution.js";
3
3
  import { resolveGeometrySamplingLimits } from "../sampling-limits.js";
4
4
  import { requireProfileVertexCount } from "./validation.js";
5
- const OPERATION = "sampleOpenProfile";
5
+ const OPERATION = "sampleSideProfile";
6
6
  const MAX_SEGMENTS = 31;
7
7
  const MAX_DEPTH = 24;
8
8
  function invalid(message) {
@@ -35,13 +35,13 @@ function distanceToChord(point, start, end) {
35
35
  *
36
36
  * @example
37
37
  * ```ts
38
- * const side = sampleOpenProfile({
38
+ * const side = sampleSideProfile({
39
39
  * start: { radius: 0.5, height: 0 },
40
40
  * segments: [{ type: "line", end: { radius: 1, height: 1 } }],
41
41
  * }, { radiusScale: 12, heightScale: 30 });
42
42
  * ```
43
43
  */
44
- export function sampleOpenProfile(curve, options = {}) {
44
+ export function sampleSideProfile(curve, options = {}) {
45
45
  const radiusScale = options.radiusScale ?? 1;
46
46
  const heightScale = options.heightScale ?? 1;
47
47
  if (![radiusScale, heightScale].every((scale) => Number.isFinite(scale) && scale > 0)) {
@@ -5,6 +5,7 @@ import { resolveCircularSegments, resolveGeometryResolution, } from "./resolutio
5
5
  import { resolveGeometrySamplingLimits } from "./sampling-limits.js";
6
6
  import { strokePathMultiPolygon } from "./stroke-path.js";
7
7
  import { createBoundedSvgClipping } from "./svg/fill.js";
8
+ import { multiplyMatrices as multiply, transformPoint } from "./svg/matrix.js";
8
9
  import { flattenSvgSubpaths, SvgVertexBudget } from "./svg/path.js";
9
10
  const MAX_SVG_BYTES = 256 * 1024;
10
11
  const MAX_SVG_ELEMENTS = 500;
@@ -89,22 +90,6 @@ function finite(value, fallback) {
89
90
  const parsed = Number(value);
90
91
  return Number.isFinite(parsed) ? parsed : fail(`Invalid SVG number “${value}”.`);
91
92
  }
92
- function multiply(left, right) {
93
- return [
94
- left[0] * right[0] + left[2] * right[1],
95
- left[1] * right[0] + left[3] * right[1],
96
- left[0] * right[2] + left[2] * right[3],
97
- left[1] * right[2] + left[3] * right[3],
98
- left[0] * right[4] + left[2] * right[5] + left[4],
99
- left[1] * right[4] + left[3] * right[5] + left[5],
100
- ];
101
- }
102
- function transformPoint(matrix, point) {
103
- return [
104
- matrix[0] * point[0] + matrix[2] * point[1] + matrix[4],
105
- matrix[1] * point[0] + matrix[3] * point[1] + matrix[5],
106
- ];
107
- }
108
93
  function parseTransform(value) {
109
94
  if (value === undefined || value.trim() === "")
110
95
  return [1, 0, 0, 1, 0, 0];
@@ -280,7 +265,14 @@ function canvasTransform(values, width, height, box, origin) {
280
265
  return [appliedX, 0, 0, -appliedY, offsetX, offsetY];
281
266
  }
282
267
  function isPainted(color, opacity) {
283
- return color !== "none" && color !== "transparent" && opacity > 0;
268
+ // Mirrored by the editor's core/document.ts: embedded CSS alpha also controls geometry.
269
+ const normalized = color.trim().toLowerCase();
270
+ if (normalized === "none" || normalized === "transparent" || !(opacity > 0))
271
+ return false;
272
+ if (/^#[0-9a-f]{4}(?:[0-9a-f]{4})?$/u.test(normalized))
273
+ return Number.parseInt(normalized.slice(normalized.length === 5 ? -1 : -2), 16) > 0;
274
+ const components = /^(?:rgb|hsl)a?\((.*)\)$/u.exec(normalized)?.[1]?.split(",");
275
+ return components?.length !== 4 || Number(components[3].trim().replace(/%$/u, "")) > 0;
284
276
  }
285
277
  function ellipsePoints(cx, cy, rx, ry, segments, budget) {
286
278
  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.10.0",
4
4
  "description": "Scoped solid modeling and pure geometry foundations for Node and browsers.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {