@metamesh/modeling 0.6.0 → 0.8.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 +30 -0
- package/README.md +157 -22
- package/dist/api/engine.d.ts +25 -4
- package/dist/api/geometry.d.ts +391 -82
- package/dist/api/index.d.ts +732 -108
- package/dist/api/text.d.ts +40 -10
- package/dist/engine/construction-operations.js +52 -10
- package/dist/engine/execution.js +55 -2
- package/dist/engine/export-3mf.js +17 -1
- package/dist/engine/finalization.js +27 -18
- package/dist/engine/index.js +12 -1
- package/dist/engine/inspection-operations.js +26 -22
- package/dist/engine/loft-operations.js +1 -1
- package/dist/engine/mechanical-operations.js +5 -8
- package/dist/engine/mesh-view-cache.js +23 -0
- package/dist/engine/native/adapter.js +2 -4
- package/dist/engine/native/distance.js +42 -0
- package/dist/engine/native/status.js +16 -0
- package/dist/engine/operation-environment.js +2 -1
- package/dist/engine/section-operations.js +3 -3
- package/dist/geometry/curves/sampling.js +2 -1
- package/dist/geometry/heightfield.js +109 -42
- package/dist/geometry/index.js +2 -0
- package/dist/geometry/loft-simple-mesh.js +2 -3
- package/dist/geometry/loft-triangulation.js +12 -9
- package/dist/geometry/loft.js +18 -7
- package/dist/geometry/math/vector3.js +35 -5
- package/dist/geometry/mechanical/standards.js +2 -3
- package/dist/geometry/mechanical/thread-profiles.js +5 -0
- package/dist/geometry/mechanical/threads.js +2 -3
- package/dist/geometry/mesh-centroid.js +36 -0
- package/dist/geometry/mesh-edge-table.js +126 -0
- package/dist/geometry/mesh-solid-intersections.js +123 -0
- package/dist/geometry/mesh-solid.js +284 -0
- package/dist/geometry/mesh.js +8 -25
- package/dist/geometry/paths/path.js +3 -1
- package/dist/geometry/paths/resampling.js +2 -1
- package/dist/geometry/profiles/matched.js +8 -0
- package/dist/geometry/profiles/profiles.js +24 -3
- package/dist/geometry/profiles/rectangle.js +16 -2
- package/dist/geometry/profiles/shapes.js +8 -1
- package/dist/geometry/revolve/revolve-mesh.js +6 -0
- package/dist/geometry/sweeps/sweep-closed.js +4 -4
- package/dist/geometry/sweeps/sweep-mesh.js +13 -7
- package/dist/geometry/sweeps/sweep-open.js +4 -4
- package/dist/geometry/sweeps/validation.js +1 -2
- package/dist/model/operations.js +13 -4
- package/dist/text/index.js +11 -1
- package/dist/text/vector-text-layout.js +46 -3
- package/package.json +1 -1
|
@@ -3,7 +3,14 @@ import { resolveCircularPrimitiveSegments } from "../primitive-resolution.js";
|
|
|
3
3
|
import { assertPrimitiveOptions } from "../primitive-resolvers.js";
|
|
4
4
|
import { requireFiniteProfileValues, requireProfileVertexCount } from "./validation.js";
|
|
5
5
|
export { rectangleLoop } from "./rectangle.js";
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Creates an immutable centered CCW circle with +X seam and vertex or face radial sizing.
|
|
8
|
+
*
|
|
9
|
+
* @example Explicit circular point data
|
|
10
|
+
* ```ts
|
|
11
|
+
* circleLoop({ diameter: 20, segments: 32 });
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
7
14
|
export const circleLoop = (options) => {
|
|
8
15
|
const operation = "circleLoop";
|
|
9
16
|
assertPrimitiveOptions(options, operation);
|
|
@@ -32,7 +39,14 @@ export const circleLoop = (options) => {
|
|
|
32
39
|
return Object.freeze([sampledRadius * Math.cos(angle), sampledRadius * Math.sin(angle)]);
|
|
33
40
|
}));
|
|
34
41
|
};
|
|
35
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Creates a centered CCW point loop with vertex or face radial sizing and an implicit seam.
|
|
44
|
+
*
|
|
45
|
+
* @example Hexagon sized to its faces
|
|
46
|
+
* ```ts
|
|
47
|
+
* regularPolygonLoop({ radius: 10, sides: 6, sizing: "face" });
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
36
50
|
export const regularPolygonLoop = (options) => {
|
|
37
51
|
const operation = "regularPolygonLoop";
|
|
38
52
|
const { radius, sides, rotationDegrees = 0, sizing = "vertex" } = options;
|
|
@@ -53,7 +67,14 @@ export const regularPolygonLoop = (options) => {
|
|
|
53
67
|
return [sampledRadius * Math.cos(angle), sampledRadius * Math.sin(angle)];
|
|
54
68
|
});
|
|
55
69
|
};
|
|
56
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Creates a centered CCW alternating-radius star within the selected profile sampling ceiling.
|
|
72
|
+
*
|
|
73
|
+
* @example Five-point outline
|
|
74
|
+
* ```ts
|
|
75
|
+
* starLoop(12, 5, 6);
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
57
78
|
export const starLoop = (outerRadius, points, innerRadius = outerRadius / 2, limits = {}) => {
|
|
58
79
|
const operation = "starLoop";
|
|
59
80
|
requireFiniteProfileValues([outerRadius, innerRadius], operation);
|
|
@@ -78,7 +78,14 @@ export function rectangleCornerSegments(corner, limits) {
|
|
|
78
78
|
limits: selected,
|
|
79
79
|
}) / 4);
|
|
80
80
|
}
|
|
81
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Creates an immutable CCW rectangle loop. Round radius zero is square; tangent saturated endpoints occur once.
|
|
83
|
+
*
|
|
84
|
+
* @example Rounded rectangle outline
|
|
85
|
+
* ```ts
|
|
86
|
+
* rectangleLoop({ size: [30, 20], treatment: { kind: "round", radius: 3 } });
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
82
89
|
export function rectangleLoop(options) {
|
|
83
90
|
const { width, height, corners, limits } = resolveRectangle(options, "rectangleLoop");
|
|
84
91
|
const counts = corners.map((corner) => {
|
|
@@ -138,7 +145,14 @@ export function rectangleLoop(options) {
|
|
|
138
145
|
clean.pop();
|
|
139
146
|
return Object.freeze(clean.map(([px, py]) => Object.freeze(options.center === false ? [px + x, py + y] : [px, py])));
|
|
140
147
|
}
|
|
141
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* Creates an identified rectangle whose edge/corner spans survive zero and saturated round radii. Outer winding is CCW.
|
|
150
|
+
*
|
|
151
|
+
* @example Retain rectangle landmarks
|
|
152
|
+
* ```ts
|
|
153
|
+
* rectangleProfile({ size: [30, 20], treatment: { kind: "round", radius: 3 } });
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
142
156
|
export function rectangleProfile(options) {
|
|
143
157
|
const { width, height, corners, setbacks, limits } = resolveRectangle(options, "rectangleProfile");
|
|
144
158
|
const id = options.id ?? "body";
|
|
@@ -2,7 +2,14 @@ import { GeometryError } from "../errors.js";
|
|
|
2
2
|
import { resolveCircularPrimitiveSegments } from "../primitive-resolution.js";
|
|
3
3
|
import { assertPrimitiveOptions } from "../primitive-resolvers.js";
|
|
4
4
|
import { requireFiniteProfileValues, requireProfileVertexCount } from "./validation.js";
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Creates an immutable centered CCW ellipse with +X seam within the selected sampling ceiling.
|
|
7
|
+
*
|
|
8
|
+
* @example Ellipse by full dimensions
|
|
9
|
+
* ```ts
|
|
10
|
+
* ellipseLoop({ size: [30, 20] });
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
6
13
|
export function ellipseLoop(options) {
|
|
7
14
|
assertPrimitiveOptions(options, "ellipseLoop");
|
|
8
15
|
if ((options.radius === undefined) === (options.size === undefined)) {
|
|
@@ -20,6 +20,12 @@ const AXIS_EPSILON = 1e-10;
|
|
|
20
20
|
* @param profile - Closed CCW `[radius, z]` profile without a duplicated seam point.
|
|
21
21
|
* @param options - Optional physical or exact circumferential sampling controls. Defaults to `{}`.
|
|
22
22
|
* @returns A closed indexed triangle mesh revolved counter-clockwise around +Z.
|
|
23
|
+
*
|
|
24
|
+
* @example Partial revolution with end caps
|
|
25
|
+
* ```ts
|
|
26
|
+
* // Each point is [radius, Z]; the profile seam is implicit.
|
|
27
|
+
* revolveMesh([[0, 0], [12, 0], [8, 20], [0, 20]], { revolveDegrees: 270 });
|
|
28
|
+
* ```
|
|
23
29
|
*/
|
|
24
30
|
export const revolveMesh = (profile, options = {}) => {
|
|
25
31
|
if (typeof options !== "object" || options === null || Array.isArray(options))
|
|
@@ -7,12 +7,12 @@ import { computeSweepFrames, validateSweepOptions } from "./orientation.js";
|
|
|
7
7
|
import { planSweepStations, refineSweepStations } from "./stations.js";
|
|
8
8
|
import { assertSweepAllocation, normalizeSweepPath, normalizeSweepProfile, writeSweepVertex, } from "./validation.js";
|
|
9
9
|
/** @internal Checks host allocation policy before frames or manufacturing buffers. */
|
|
10
|
-
export const buildClosedSweepMesh = (profile, path, options = {}
|
|
10
|
+
export const buildClosedSweepMesh = (profile, path, options = {}) => {
|
|
11
11
|
const operation = "sweep";
|
|
12
12
|
validateSweepOptions(options);
|
|
13
13
|
profile = normalizeSweepProfile(profile, operation, options.limits);
|
|
14
14
|
path = normalizeSweepPath(path, true, operation, options.limits);
|
|
15
|
-
assertSweepAllocation(path.length, profile.length, true, operation,
|
|
15
|
+
assertSweepAllocation(path.length, profile.length, true, operation, options.limits);
|
|
16
16
|
let scales = resolveSweepScales(options.scale, path.length);
|
|
17
17
|
const seamProfileOffset = options.seamProfileOffset ?? 0;
|
|
18
18
|
if (!Number.isSafeInteger(seamProfileOffset)) {
|
|
@@ -44,13 +44,13 @@ export const buildClosedSweepMesh = (profile, path, options = {}, allocationGuar
|
|
|
44
44
|
}
|
|
45
45
|
if (twistRadians !== 0) {
|
|
46
46
|
const plan = planSweepStations([profile], path, scales, options);
|
|
47
|
-
assertSweepAllocation(plan.stationCount, profile.length, true, operation,
|
|
47
|
+
assertSweepAllocation(plan.stationCount, profile.length, true, operation, options.limits);
|
|
48
48
|
}
|
|
49
49
|
let transportedFrames = computeSweepFrames(path, options);
|
|
50
50
|
let twists;
|
|
51
51
|
if (twistRadians !== 0) {
|
|
52
52
|
const plan = planSweepStations([profile], path, scales, options, transportedFrames);
|
|
53
|
-
assertSweepAllocation(plan.stationCount, profile.length, true, operation,
|
|
53
|
+
assertSweepAllocation(plan.stationCount, profile.length, true, operation, options.limits);
|
|
54
54
|
const refined = refineSweepStations(path, scales, transportedFrames, options, plan);
|
|
55
55
|
path = refined.path;
|
|
56
56
|
scales = refined.scales;
|
|
@@ -2,19 +2,25 @@ import { GeometryError } from "../errors.js";
|
|
|
2
2
|
import { validateSweepOptions } from "./orientation.js";
|
|
3
3
|
import { buildClosedSweepMesh } from "./sweep-closed.js";
|
|
4
4
|
import { buildOpenSweepMesh } from "./sweep-open.js";
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Sweeps one explicitly ordered point loop, with caps or a geometrically matching closed seam.
|
|
7
|
+
*
|
|
8
|
+
* @example Sweep point data along a bent path
|
|
9
|
+
* ```ts
|
|
10
|
+
* const profile = rectangleLoop({ size: [6, 3] });
|
|
11
|
+
* sweepMesh(profile, [[0, 0, 0], [0, 0, 15], [10, 0, 25]], {
|
|
12
|
+
* orientation: { kind: "transport", initialNormal: [0, 1, 0] },
|
|
13
|
+
* });
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
6
16
|
export function sweepMesh(profile, path, options = {}) {
|
|
7
|
-
return buildSweepMesh(profile, path, options);
|
|
8
|
-
}
|
|
9
|
-
/** @internal Applies host ceilings before frames, caps, or manufacturing buffers. */
|
|
10
|
-
export function buildSweepMesh(profile, path, options = {}, allocationGuard) {
|
|
11
17
|
validateSweepOptions(options);
|
|
12
18
|
if (options.closed === true) {
|
|
13
|
-
return buildClosedSweepMesh(profile, path, options
|
|
19
|
+
return buildClosedSweepMesh(profile, path, options);
|
|
14
20
|
}
|
|
15
21
|
if ((options.seamProfileOffset ?? 0) !== 0)
|
|
16
22
|
throw new GeometryError("INVALID_ARGUMENT", "A seamProfileOffset requires a closed path.", {
|
|
17
23
|
operation: "sweep",
|
|
18
24
|
});
|
|
19
|
-
return buildOpenSweepMesh(profile, path, options
|
|
25
|
+
return buildOpenSweepMesh(profile, path, options);
|
|
20
26
|
}
|
|
@@ -6,23 +6,23 @@ import { computeSweepFrames, validateSweepOptions } from "./orientation.js";
|
|
|
6
6
|
import { planSweepStations, refineSweepStations } from "./stations.js";
|
|
7
7
|
import { assertSweepAllocation, normalizeSweepPath, normalizeSweepProfile, writeSweepVertex, } from "./validation.js";
|
|
8
8
|
/** @internal Checks host allocation policy before frames, caps or manufacturing buffers. */
|
|
9
|
-
export const buildOpenSweepMesh = (profile, path, options = {}
|
|
9
|
+
export const buildOpenSweepMesh = (profile, path, options = {}) => {
|
|
10
10
|
const operation = "sweep";
|
|
11
11
|
validateSweepOptions(options);
|
|
12
12
|
profile = normalizeSweepProfile(profile, operation, options.limits);
|
|
13
13
|
path = normalizeSweepPath(path, false, operation, options.limits);
|
|
14
|
-
assertSweepAllocation(path.length, profile.length, false, operation,
|
|
14
|
+
assertSweepAllocation(path.length, profile.length, false, operation, options.limits);
|
|
15
15
|
let scales = resolveSweepScales(options.scale, path.length);
|
|
16
16
|
const twistRadians = ((options.twistDegrees ?? 0) / 180) * Math.PI;
|
|
17
17
|
if (twistRadians !== 0) {
|
|
18
18
|
const plan = planSweepStations([profile], path, scales, options);
|
|
19
|
-
assertSweepAllocation(plan.stationCount, profile.length, false, operation,
|
|
19
|
+
assertSweepAllocation(plan.stationCount, profile.length, false, operation, options.limits);
|
|
20
20
|
}
|
|
21
21
|
let frames = computeSweepFrames(path, options);
|
|
22
22
|
let twists;
|
|
23
23
|
if (twistRadians !== 0) {
|
|
24
24
|
const plan = planSweepStations([profile], path, scales, options, frames);
|
|
25
|
-
assertSweepAllocation(plan.stationCount, profile.length, false, operation,
|
|
25
|
+
assertSweepAllocation(plan.stationCount, profile.length, false, operation, options.limits);
|
|
26
26
|
const refined = refineSweepStations(path, scales, frames, options, plan);
|
|
27
27
|
path = refined.path;
|
|
28
28
|
scales = refined.scales;
|
|
@@ -87,11 +87,10 @@ export function normalizeSweepPath(path, closed, operation, limits = {}) {
|
|
|
87
87
|
return Object.freeze(cleaned);
|
|
88
88
|
}
|
|
89
89
|
/** @internal Checks output allocation before creating frames or buffers. */
|
|
90
|
-
export function assertSweepAllocation(pathCount, profileCount, closed, operation,
|
|
90
|
+
export function assertSweepAllocation(pathCount, profileCount, closed, operation, limits = {}) {
|
|
91
91
|
const vertexCount = pathCount * profileCount;
|
|
92
92
|
const triangleCount = (closed ? pathCount : pathCount - 1) * profileCount * 2 + (closed ? 0 : (profileCount - 2) * 2);
|
|
93
93
|
assertMeshAllocationWithinLimits(vertexCount, triangleCount, (vertexCount + triangleCount) * 12, operation, resolveModelingLimits(limits));
|
|
94
|
-
allocationGuard?.(vertexCount, triangleCount, (vertexCount + triangleCount) * 12, operation);
|
|
95
94
|
}
|
|
96
95
|
/** @internal Rejects coordinates that cannot be represented in manufacturing mesh buffers. */
|
|
97
96
|
export function writeSweepVertex(positions, offset, point, operation) {
|
package/dist/model/operations.js
CHANGED
|
@@ -3,8 +3,6 @@ function operation(name) {
|
|
|
3
3
|
const invoke = (...args) => {
|
|
4
4
|
const context = currentModelContext(name);
|
|
5
5
|
const method = context[name];
|
|
6
|
-
if (typeof method !== "function")
|
|
7
|
-
throw new Error(`Unknown modeling operation "${name}".`);
|
|
8
6
|
return Reflect.apply(method, undefined, args);
|
|
9
7
|
};
|
|
10
8
|
return invoke;
|
|
@@ -14,14 +12,16 @@ export const matchProfiles =
|
|
|
14
12
|
/* @__PURE__ */ operation("matchProfiles");
|
|
15
13
|
/** Copies intersections along a finite directed ray segment during the active build. */
|
|
16
14
|
export const rayCast = /* @__PURE__ */ operation("rayCast");
|
|
17
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Returns bound local operations, optionally with derived resolution, without changing imported operations.
|
|
17
|
+
*/
|
|
18
18
|
export function modelingFunctions(resolution) {
|
|
19
19
|
const context = currentModelContext("modelingFunctions");
|
|
20
20
|
return resolution === undefined ? context : context.withResolution(resolution);
|
|
21
21
|
}
|
|
22
22
|
/** Host-bound box operation. Valid only during a synchronous build. */
|
|
23
23
|
export const box = /* @__PURE__ */ operation("box");
|
|
24
|
-
/** Constructs a closed
|
|
24
|
+
/** Constructs a closed rectilinear heightfield during the active synchronous build. */
|
|
25
25
|
export const heightfield = /* @__PURE__ */ operation("heightfield");
|
|
26
26
|
/** Returns an immutable copy with the explicit native millimetre tolerance. */
|
|
27
27
|
export const setTolerance =
|
|
@@ -40,6 +40,11 @@ export const union = /* @__PURE__ */ operation("union");
|
|
|
40
40
|
export const subtract = /* @__PURE__ */ operation("subtract");
|
|
41
41
|
/** Host-bound intersect operation. Valid only during a synchronous build. */
|
|
42
42
|
export const intersect = /* @__PURE__ */ operation("intersect");
|
|
43
|
+
/** Returns intersection and difference with a same-build cutter, including empty results. */
|
|
44
|
+
export const split = /* @__PURE__ */ operation("split");
|
|
45
|
+
/** Splits a solid into the normal-side and opposite-side halves of a world-space plane. */
|
|
46
|
+
export const splitByPlane =
|
|
47
|
+
/* @__PURE__ */ operation("splitByPlane");
|
|
43
48
|
/** Host-bound compose operation. Valid only during a synchronous build. */
|
|
44
49
|
export const compose = /* @__PURE__ */ operation("compose");
|
|
45
50
|
/** Host-bound hull operation. Valid only during a synchronous build. */
|
|
@@ -96,6 +101,10 @@ export const inspectSection =
|
|
|
96
101
|
/** Host-bound inspectSolid operation. Valid only during a synchronous build. */
|
|
97
102
|
export const inspectSolid =
|
|
98
103
|
/* @__PURE__ */ operation("inspectSolid");
|
|
104
|
+
/** Tests a solid or filled section in the active build, including expected empty Boolean results. */
|
|
105
|
+
export const isEmpty = /* @__PURE__ */ operation("isEmpty");
|
|
106
|
+
/** Measures the minimum separation between two nonempty solids in millimetres. */
|
|
107
|
+
export const distance = /* @__PURE__ */ operation("distance");
|
|
99
108
|
/** Host-bound components operation. Valid only during a synchronous build. */
|
|
100
109
|
export const components = /* @__PURE__ */ operation("components");
|
|
101
110
|
/** Host-bound simplify operation. Valid only during a synchronous build. */
|
package/dist/text/index.js
CHANGED
|
@@ -19,7 +19,17 @@ function serializeVectorText({ paths, metrics }) {
|
|
|
19
19
|
fail("Text outlines exceed 256 KiB. Use fewer characters or a simpler font.");
|
|
20
20
|
return svg;
|
|
21
21
|
}
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Parses bounded font bytes using optional fontkit and WOFF2 peers; no font fetching occurs here.
|
|
24
|
+
*
|
|
25
|
+
* @example Prepare a label before building geometry
|
|
26
|
+
* ```ts
|
|
27
|
+
* async function prepareLabel(fontBytes: Uint8Array) {
|
|
28
|
+
* const font = await prepareVectorFont(fontBytes);
|
|
29
|
+
* return font.layout("MetaMesh", { fontSize: 8 });
|
|
30
|
+
* }
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
23
33
|
export async function prepareVectorFont(bytes, options = {}) {
|
|
24
34
|
if (bytes.byteLength === 0 || bytes.byteLength > MAX_VECTOR_FONT_BYTES)
|
|
25
35
|
fail("Font files must be non-empty and at most 16 MiB.");
|
|
@@ -22,13 +22,33 @@ function checkText(text) {
|
|
|
22
22
|
return lines;
|
|
23
23
|
}
|
|
24
24
|
function normalizeControls(options, staticWeight) {
|
|
25
|
+
const baseline = options.baseline;
|
|
26
|
+
if (baseline !== undefined) {
|
|
27
|
+
if (typeof baseline !== "object" || baseline === null || baseline.kind !== "arc")
|
|
28
|
+
fail("Invalid text baseline; use an arc or omit baseline for horizontal text.");
|
|
29
|
+
if (!Number.isFinite(baseline.radius) || baseline.radius <= 0 || baseline.radius > 10000)
|
|
30
|
+
fail("Arc baseline radius must be positive and at most 10000 mm.");
|
|
31
|
+
if (baseline.centerAngleDegrees !== undefined && !Number.isFinite(baseline.centerAngleDegrees))
|
|
32
|
+
fail("Arc baseline centerAngleDegrees must be finite.");
|
|
33
|
+
if (baseline.direction !== undefined &&
|
|
34
|
+
baseline.direction !== "clockwise" &&
|
|
35
|
+
baseline.direction !== "counterclockwise")
|
|
36
|
+
fail("Invalid arc baseline direction.");
|
|
37
|
+
}
|
|
25
38
|
const controls = {
|
|
26
39
|
fontSize: options.fontSize,
|
|
27
40
|
fontWeight: options.fontWeight ?? staticWeight,
|
|
28
41
|
letterSpacing: options.letterSpacing ?? 0,
|
|
29
|
-
align: options.align ?? "left",
|
|
42
|
+
align: options.align ?? (baseline === undefined ? "left" : "center"),
|
|
30
43
|
verticalAlign: options.verticalAlign ?? "baseline",
|
|
31
44
|
lineHeight: options.lineHeight,
|
|
45
|
+
baseline: baseline === undefined
|
|
46
|
+
? undefined
|
|
47
|
+
: {
|
|
48
|
+
radius: baseline.radius,
|
|
49
|
+
centerAngleDegrees: (((baseline.centerAngleDegrees ?? 90) % 360) + 360) % 360,
|
|
50
|
+
direction: baseline.direction ?? "clockwise",
|
|
51
|
+
},
|
|
32
52
|
};
|
|
33
53
|
if (!Number.isFinite(controls.fontSize) || controls.fontSize <= 0 || controls.fontSize > 10000)
|
|
34
54
|
fail("fontSize must be positive and at most 10000 mm.");
|
|
@@ -45,6 +65,8 @@ function normalizeControls(options, staticWeight) {
|
|
|
45
65
|
controls.verticalAlign !== "center" &&
|
|
46
66
|
controls.verticalAlign !== "bottom")
|
|
47
67
|
fail("Invalid vertical text alignment.");
|
|
68
|
+
if (controls.baseline !== undefined && controls.verticalAlign !== "baseline")
|
|
69
|
+
fail("Arc text requires baseline vertical alignment.");
|
|
48
70
|
if (controls.lineHeight !== undefined &&
|
|
49
71
|
(!Number.isFinite(controls.lineHeight) || controls.lineHeight < 0.5 || controls.lineHeight > 5))
|
|
50
72
|
fail("lineHeight must be between 0.5 and 5.");
|
|
@@ -189,6 +211,8 @@ function buildLayout(font, fontMetrics, lines, controls) {
|
|
|
189
211
|
let commands = 0;
|
|
190
212
|
for (let lineIndex = 0; lineIndex < shaped.length; lineIndex++) {
|
|
191
213
|
const { run, advanceWidth } = shaped[lineIndex];
|
|
214
|
+
if (controls.baseline !== undefined && advanceWidth / controls.baseline.radius >= Math.PI * 2)
|
|
215
|
+
fail("Arc text must span less than one full revolution. Increase radius or reduce text size or spacing.");
|
|
192
216
|
const lineX = controls.align === "center"
|
|
193
217
|
? -advanceWidth / 2
|
|
194
218
|
: controls.align === "right"
|
|
@@ -197,11 +221,16 @@ function buildLayout(font, fontMetrics, lines, controls) {
|
|
|
197
221
|
let x = 0;
|
|
198
222
|
let y = 0;
|
|
199
223
|
let spacingSteps = -1;
|
|
224
|
+
let groupCenter = 0;
|
|
200
225
|
for (let index = 0; index < run.glyphs.length; index++) {
|
|
201
226
|
const position = run.positions[index];
|
|
202
227
|
// Zero-advance marks retain their base glyph's tracking offset.
|
|
203
|
-
if (index === 0 || position.xAdvance !== 0)
|
|
228
|
+
if (index === 0 || position.xAdvance !== 0) {
|
|
204
229
|
spacingSteps++;
|
|
230
|
+
// Keep zero-advance marks on the same rigid frame as their advancing base glyph.
|
|
231
|
+
groupCenter =
|
|
232
|
+
(x + position.xAdvance / 2) * scale + spacingSteps * controls.letterSpacing + lineX;
|
|
233
|
+
}
|
|
205
234
|
const source = run.glyphs[index].path.commands;
|
|
206
235
|
commands += source.length;
|
|
207
236
|
if (commands > MAX_TEXT_COMMANDS)
|
|
@@ -209,9 +238,21 @@ function buildLayout(font, fontMetrics, lines, controls) {
|
|
|
209
238
|
if (source.length > 0) {
|
|
210
239
|
// Preserve full precision: fontkit's toSVG() rounds before scaling and reparsing it wastes work.
|
|
211
240
|
const path = new SVGPathData(source.map(convertCommand)).transform(SVGPathDataTransformer.MATRIX(scale, 0, 0, scale, (x + position.xOffset) * scale + spacingSteps * controls.letterSpacing + lineX, (y + position.yOffset) * scale - lineIndex * lineHeight));
|
|
212
|
-
|
|
241
|
+
let bounds = path.getBounds();
|
|
213
242
|
if (![bounds.minX, bounds.minY, bounds.maxX, bounds.maxY].every(Number.isFinite))
|
|
214
243
|
fail("The font returned invalid glyph outline bounds.");
|
|
244
|
+
if (controls.baseline !== undefined) {
|
|
245
|
+
const { radius, centerAngleDegrees, direction } = controls.baseline;
|
|
246
|
+
const clockwise = direction === "clockwise";
|
|
247
|
+
if (clockwise ? radius + bounds.minY <= 0 : radius - bounds.maxY <= 0)
|
|
248
|
+
fail("Arc glyph outlines cross the circle's center axis. Increase the radius or reduce text size.");
|
|
249
|
+
const angle = (centerAngleDegrees * Math.PI) / 180 + ((clockwise ? -1 : 1) * groupCenter) / radius;
|
|
250
|
+
const rotation = angle + (clockwise ? -Math.PI / 2 : Math.PI / 2);
|
|
251
|
+
const cosine = Math.cos(rotation);
|
|
252
|
+
const sine = Math.sin(rotation);
|
|
253
|
+
path.transform(SVGPathDataTransformer.MATRIX(cosine, sine, -sine, cosine, radius * Math.cos(angle) - cosine * groupCenter, radius * Math.sin(angle) - sine * groupCenter));
|
|
254
|
+
bounds = path.getBounds();
|
|
255
|
+
}
|
|
215
256
|
minX = Math.min(minX, bounds.minX);
|
|
216
257
|
minY = Math.min(minY, bounds.minY);
|
|
217
258
|
maxX = Math.max(maxX, bounds.maxX);
|
|
@@ -261,6 +302,8 @@ export function createVectorTextLayout(font, staticWeight) {
|
|
|
261
302
|
return (text, options) => {
|
|
262
303
|
const lines = checkText(text);
|
|
263
304
|
const controls = normalizeControls(options, staticWeight);
|
|
305
|
+
if (controls.baseline !== undefined && lines.length !== 1)
|
|
306
|
+
fail("Arc text supports exactly one line.");
|
|
264
307
|
const key = JSON.stringify([lines, controls]);
|
|
265
308
|
if (previous?.key === key)
|
|
266
309
|
return previous.layout;
|