1
0
Fork 0
freesewing/plugins/plugin-path-utils/src/index.mjs
Jonathan Haas 04a0b4b099 [plugin-path-utils] feat: Add path-utils plug-in (#236)
This plug-in helps with creating seam allowance and hem paths.

Rebased v4 version for #99, see the linked issue for screenshots/details.

Reviewed-on: https://codeberg.org/freesewing/freesewing/pulls/236
Reviewed-by: Joost De Cock <joostdecock@noreply.codeberg.org>
Co-authored-by: Jonathan Haas <haasjona@gmail.com>
Co-committed-by: Jonathan Haas <haasjona@gmail.com>
2025-04-13 08:58:45 +00:00

499 lines
14 KiB
JavaScript

import about from '../about.json' with { type: 'json' }
/**
* Fills in a corner between two paths with the given mode
*
* @param {Path} result
* @param {Path} prev
* @param {Path} next
* @param {string} mode currently supported are 'cut' and 'corner'
* @param {number} limit
* @param utils
*/
function insertJoin(result, prev, next, mode, limit, utils) {
if (prev.ops.length < 2) {
return
}
if (next.ops.length < 2) {
return
}
if (prev.start().sitsOn(prev.end())) {
// no start angle determinable
return
}
if (next.start().sitsOn(next.end())) {
// no end angle determinable
return
}
if (prev.end().sitsOn(next.start())) {
// No join necessary
return
}
if (mode === 'cut') {
return
}
const reversed = prev.reverse()
const prevPoint = reversed.ops[0].to
const nextPoint = next.ops[0].to
if (prevPoint.dist(nextPoint) < 1) {
// No join necessary
return
}
let prevCp = reversed.ops[1].cp1 ?? reversed.ops[1].to
let nextCp = next.ops[1].cp1 ?? next.ops[1].to
if (prevPoint.sitsOn(prevCp)) {
// We need to calculate a previous point if the control point already lies on the end of the line
prevCp = prevPoint.shift(prev.angleAt(prevPoint), -66)
}
if (nextPoint.sitsOn(nextCp)) {
// We need to calculate a previous point if the control point already lies on the end of the line
nextCp = nextPoint.shift(next.angleAt(nextPoint), 66)
}
const intersect = utils.beamsIntersect(prevCp, prevPoint, nextPoint, nextCp)
if (intersect) {
const d1 = intersect.dist(prevPoint)
const d2 = intersect.dist(nextPoint)
if (d1 > intersect.dist(prevCp)) {
// dont go backwards
return
}
if (d2 > intersect.dist(nextCp)) {
// dont go backwards
return
}
if (mode === 'corner') {
if (limit) {
const min = Math.min(d1, d2)
if (d1 > limit && d2 > limit) {
const p1 = intersect.shiftTowards(prevPoint, min - limit)
const p2 = intersect.shiftTowards(nextPoint, min - limit)
result.line(p1).line(p2)
return
}
}
result.line(intersect)
}
}
}
function loadPaths(configuredPaths, allPaths, log) {
let result = []
for (const configuredPath of configuredPaths) {
if (typeof configuredPath === 'string') {
const p = allPaths[configuredPath]
if (p && p.ops) {
// note: instanceof would fail due to different constructors
result.push(p)
} else {
log.error(`Path "${configuredPath}" is not a valid path`)
}
} else if (configuredPath && configuredPath.ops) {
result.push(configuredPath)
} else if (configuredPath !== null) {
log.error(`Path "${configuredPath}" is not a valid path`)
} else {
result.push(null)
}
}
return result
}
/**
* Joins paths together with angled corners.
*
* Node: elements in the `paths` array, that are hidden with [Path.hide()] will stay invisible in the output, but will
* still create corners
*
* @param conf {object}
* @property {object[]} paths
* @property {string} mode
* @property {number} limit
* @param {object} props
*
* @return {Path} the resulting Path object that consists of the joined paths
*/
const joinMacro = function (conf, props) {
let { log, Path } = props
const paths = loadPaths(conf.paths, props.paths, log)
let prevPathIndex = paths.length - 1
let result = new Path()
const mode = conf.mode ?? 'corner'
// remove intersecting sections of adjacent paths that e.g. occur when offsetting a concave corner
paths.forEach(function (path, index) {
if (path !== null) {
if (paths[prevPathIndex] !== null) {
// check intersection
const intersections = paths[prevPathIndex].intersects(path)
if (intersections.length > 0) {
const intersection = intersections.pop()
paths[prevPathIndex] = paths[prevPathIndex].split(intersection)[0] ?? paths[prevPathIndex]
paths[index] = path.split(intersection).pop() ?? path
}
}
}
prevPathIndex = index
})
paths.forEach(function (path, index) {
if (path !== null) {
const prevPath = paths[prevPathIndex]
if (prevPath !== null) {
if (path.hidden && prevPath.hidden) {
// nothing to do, both paths are hidden, so no join needed
prevPathIndex = index
return
}
if (result.ops.length === 0) {
// make sure the path starts with a move
result.move(paths[prevPathIndex].end())
}
insertJoin(result, paths[prevPathIndex], path, mode, conf.limit, props.utils)
if (!path.hidden) {
result.line(path.start())
result.ops.push(...path.ops.slice(1))
}
} else if (!path.hidden) {
result.ops.push(...path.ops)
}
}
prevPathIndex = index
})
return result
}
/**
* Calculates an offset path from a list of paths with given offset
*
* @param conf {object}
* @property {object[]} paths
* @property {string} mode
* @property {number} limit
* @param {object} props
*
* @return {Path} the resulting Path object that consists of the offset and joined paths
*/
const offsetMacro = function (conf, props) {
const segments = []
let prevPath = 'unset'
let firstPath = 'unset'
let { log, Path } = props
for (const entry of conf.paths) {
let paths = entry?.p ?? [null]
let offset = entry?.offset ?? 0
let hide = entry?.hidden ?? false
if (!Array.isArray(paths)) {
paths = [paths]
}
paths = loadPaths(paths, props.paths, log)
for (const path of paths) {
if (path == null) {
if (firstPath === 'unset') {
firstPath = null
}
if (prevPath.ops) {
// insert dummy node to make path go to the endpoint of the previous path (to return to the baseline)
segments.push(new Path().move(prevPath.end()).line(prevPath.end()))
segments.push(null)
prevPath = null
}
} else {
if (prevPath === null) {
// insert dummy node to make path go to the start point of the current path (before going sideways)
segments.push(new Path().move(path.start()).line(path.start()))
}
const division = path.divide()
for (const divisionElement of division) {
if (firstPath === 'unset') {
firstPath = divisionElement
}
if (
divisionElement.ops.length === 2 &&
divisionElement.ops[1].type === 'line' &&
divisionElement.ops[0].to.sitsOn(divisionElement.ops[1].to)
) {
continue
}
const offsetPath = divisionElement.offset(offset)
if (offsetPath.ops.length > 1) {
// skip degenerate paths
if (hide) offsetPath.hide()
segments.push(offsetPath)
prevPath = divisionElement
}
}
}
}
}
if (firstPath === null && prevPath !== null) {
// insert gap at end of path
segments.push(new Path().move(prevPath.start()).line(prevPath.start()))
}
if (firstPath !== null && prevPath === null) {
// insert gap at start of path
segments.unshift(new Path().move(firstPath.start()).line(firstPath.start()))
}
return joinMacro({ ...conf, paths: segments }, props)
}
/**
* Creates a seam allowance path from a lists of paths (optionally with assigned seam allowance override)
*
* @param conf {object}
* @property {object[]} paths
* @property {string|null} class
* @property {string} mode
* @property {number} limit
* @param {object} props
*
* @return {Path} the resulting seam allowance path
*/
const saMacro = function (conf, props) {
let { log } = props
let sa = conf.sa ?? props.sa
if (!sa) {
sa = 0
}
let offsetConf = { ...conf, paths: [] }
for (const entry of conf.paths) {
if (entry === null) {
offsetConf.paths.push(null)
} else if (entry.ops || typeof entry === 'string') {
offsetConf.paths.push({ p: entry, offset: sa })
} else if (entry.p && typeof entry.offset === 'number') {
offsetConf.paths.push(entry)
} else {
log.error(`Entry "${entry}" is not a valid seam allowance path specification`)
}
}
let result = offsetMacro(offsetConf, props)
result.setClass(conf.class ?? 'fabric')
result.addClass('sa')
return result
}
/**
* Creates a hem allowance path with trueing
*
* @param cssClass css class for resulting paths
* @param path1 path before the hem (e.g. the inseam)
* @param path2 path after the hem (e.g. the outseam)
* @param offset1 seam allowance (offset) used on `path1`
* @param offset2 seam allowance (offset) used on `path2`
* @param hemWidth width of hem
* @param lastFoldWidth width of last fold
* @param folds number of folds (you need two folds for an enclosed raw edge)
* @param prefix prefix for created paths
* @param props
*/
const hemMacro = function (
{
path1,
path2,
offset1 = null,
offset2 = null,
hemWidth,
lastFoldWidth = null,
folds = 2,
prefix: prefix = 'hemMacro',
cssClass = 'fabric',
},
props
) {
let { sa, utils, log, paths, Point, Path } = props
const lineValues = (start, end) => {
const { x: x1, y: y1 } = start
const { x: x2, y: y2 } = end
const [A, B] = [-(y2 - y1), x2 - x1]
const C = -(A * x1 + B * y1)
return [A, B, C]
}
const mirrorGen = (start, end) => {
const [A, B, C] = lineValues(start, end)
return (point) => {
const { x, y } = point
const uNom = (B * B - A * A) * x - 2 * A * B * y - 2 * A * C
const vNom = (A * A - B * B) * y - 2 * A * B * x - 2 * B * C
const denom = A * A + B * B
return new Point(uNom / denom, vNom / denom)
}
}
const mirrorPath = (mirrorPoint, path) => {
const p = path.clone()
for (const op of p.ops) {
switch (op.type) {
case 'move':
case 'line':
op.to = mirrorPoint(op.to)
break
case 'curve':
op.to = mirrorPoint(op.to)
op.cp1 = mirrorPoint(op.cp1)
op.cp2 = mirrorPoint(op.cp2)
break
default:
// Do nothing
}
}
return p.reverse()
}
if (!lastFoldWidth) {
lastFoldWidth = sa > 0 ? sa : 10
}
path1 = loadPaths([path1], paths, log).pop()
path2 = loadPaths([path2], paths, log).shift()
if (offset1 === null) {
offset1 = sa
}
if (offset2 === null) {
offset2 = sa
}
let hemStart = path1.end()
let hemEnd = path2.start()
let foldStart = hemStart
let foldEnd = hemEnd
let hemAngle = hemStart.angle(hemEnd)
// Beam that determines which part of the side seams to mirror
let innerBeamStart = hemStart.shift(hemAngle + 90, hemWidth)
let innerBeamEnd = hemEnd.shift(hemAngle + 90, hemWidth)
if (offset1) {
path1 = path1.offset(offset1)
}
if (offset2) {
path2 = path2.offset(offset2)
}
if (0 === path1.intersectsBeam(innerBeamStart, innerBeamEnd).length) {
// extend path1 to beam
let p1tmp = utils.beamsIntersect(
path1.end(),
path1.end().shift(hemAngle + 90, 10),
innerBeamStart,
innerBeamEnd
)
path1 = new Path().move(p1tmp).join(path1)
}
if (0 === path2.intersectsBeam(innerBeamStart, innerBeamEnd).length) {
// extend path2 to beam
let p2tmp = utils.beamsIntersect(
path2.start(),
path2.start().shift(hemAngle + 90, 10),
innerBeamStart,
innerBeamEnd
)
path2 = path2.clone().line(p2tmp)
}
function cutPathStart(path, beamStart, beamEnd) {
let path2Intersections = path.intersectsBeam(beamStart, beamEnd)
let path2Tmp =
path2Intersections.length === 0 ? path : path.split(path2Intersections.shift()).shift()
if (path2Tmp === null) {
path2Tmp = path
}
return path2Tmp
}
function cutPathEnd(path, beamStart, beamEnd) {
let path1Intersections = path.intersectsBeam(beamStart, beamEnd)
let path1Tmp =
path1Intersections.length === 0 ? path : path.split(path1Intersections.pop()).pop()
if (path1Tmp === null) {
path1Tmp = path
}
return path1Tmp
}
// Determine portion of side seam paths to mirror
let path1End = cutPathStart(cutPathEnd(path1, innerBeamStart, innerBeamEnd), hemStart, hemEnd)
let path2Start = cutPathEnd(cutPathStart(path2, innerBeamStart, innerBeamEnd), hemStart, hemEnd)
paths[prefix + 'Mirror1'] = path1End.clone().hide()
paths[prefix + 'Mirror2'] = path2Start.clone().hide()
let mirrorPoint = mirrorGen(foldStart, foldEnd)
let path1Mirrored = mirrorPath(mirrorPoint, path1End)
let path2Mirrored = mirrorPath(mirrorPoint, path2Start)
let startSidePaths = [path1Mirrored]
let endSidePaths = [path2Mirrored]
for (let i = 1; i < folds; i++) {
paths[prefix + 'Fold' + i] = new Path()
.move(path1Mirrored.end())
.line(path2Mirrored.start())
.setClass(cssClass)
.addClass('help')
// create additional folds around the new "hem" line
foldStart = foldStart.shift(hemAngle - 90, hemWidth)
foldEnd = foldEnd.shift(hemAngle - 90, hemWidth)
mirrorPoint = mirrorGen(foldStart, foldEnd)
path1Mirrored = mirrorPath(mirrorPoint, path1Mirrored)
path2Mirrored = mirrorPath(mirrorPoint, path2Mirrored)
startSidePaths.push(path1Mirrored)
endSidePaths.unshift(path2Mirrored)
}
foldStart = foldStart.shift(hemAngle - 90, lastFoldWidth)
foldEnd = foldEnd.shift(hemAngle - 90, lastFoldWidth)
startSidePaths[startSidePaths.length - 1] = cutPathStart(
startSidePaths[startSidePaths.length - 1],
foldStart,
foldEnd
)
endSidePaths[0] = cutPathEnd(endSidePaths[0], foldStart, foldEnd)
let joinPaths = [
...startSidePaths,
new Path().move(foldStart).line(foldEnd),
...endSidePaths,
null,
]
return joinMacro(
{
paths: joinPaths,
},
props
)
.setClass(cssClass)
.addClass('sa')
}
export const plugin = {
...about,
macros: {
join: joinMacro,
offset: offsetMacro,
sa: saMacro,
hem: hemMacro,
},
}
// More specifically named exports
export const pathUtilsPlugin = plugin
export const pluginPathUtils = plugin