@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.
- package/CHANGELOG.md +18 -0
- package/README.md +29 -415
- package/THIRD_PARTY_NOTICES.md +4 -148
- package/dist/api/geometry.d.ts +1049 -963
- package/dist/api/index.d.ts +13 -1
- package/dist/geometry/index.js +2 -1
- package/dist/geometry/profiles/contour.js +102 -0
- package/dist/geometry/profiles/{open-profile.js → side-profile.js} +3 -3
- package/dist/geometry/svg-import.js +9 -17
- package/package.json +1 -1
package/dist/api/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
/**
|
package/dist/geometry/index.js
CHANGED
|
@@ -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 {
|
|
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 = "
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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)
|