Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 8 additions & 99 deletions src/features/clothing/compiler/buildSeamConstraints.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import type { PatternDocument } from '../document/types'
import type { DistanceConstraint } from '../simulation/types'
import type { CompiledPanelSimMesh } from './buildPanelSimMesh'
import { samplePanelEdge } from './buildPanelSimMesh'

const PATTERN_UNIT_SCALE = 0.004
import { resolveSeamSamples } from '../geometry/seamUtils'

export function buildSeamConstraints(
document: PatternDocument,
Expand All @@ -19,13 +17,11 @@ export function buildSeamConstraints(
const meshB = panels[seam.b.panelId]
if (!panelA || !panelB || !meshA || !meshB) continue

const pointsA = samplePanelEdge(panelA, seam.a.edgeId, seamSamples, seam.a.reversed)
const pointsB = orientSeamSamples(
panelA,
pointsA,
panelB,
samplePanelEdge(panelB, seam.b.edgeId, seamSamples, seam.b.reversed),
)
const resolved = resolveSeamSamples(document, seam, seamSamples)
if (!resolved) continue
const pointsA = resolved.pointsA
const pointsB = resolved.pointsB
console.debug(`[SeamResolver] ${seam.id} orientation=${resolved.reversedB ? 'reversed' : 'forward'} forward=${resolved.forwardCost.toFixed(4)} reversed=${resolved.reversedCost.toFixed(4)}`)
const edgeParticlesA = orderedEdgeParticles(panelA, meshA, pointsA)
const edgeParticlesB = orderedEdgeParticles(panelB, meshB, pointsB)
const count = Math.max(edgeParticlesA.length, edgeParticlesB.length)
Expand Down Expand Up @@ -80,26 +76,6 @@ export function buildSeamConstraints(
return constraints
}

export function orientSeamSamples(
panelA: PatternDocument['panels'][string],
pointsA: Array<{ x: number; y: number }>,
panelB: PatternDocument['panels'][string],
pointsB: Array<{ x: number; y: number }>,
) {
if (pointsA.length < 2 || pointsB.length < 2) return pointsB
const lastA = pointsA.length - 1
const lastB = pointsB.length - 1
const forwardCost =
worldDistanceSq(panelA, pointsA[0], panelB, pointsB[0])
+ worldDistanceSq(panelA, pointsA[lastA], panelB, pointsB[lastB])
const reversedCost =
worldDistanceSq(panelA, pointsA[0], panelB, pointsB[lastB])
+ worldDistanceSq(panelA, pointsA[lastA], panelB, pointsB[0])
if (reversedCost + 1e-8 < forwardCost) return [...pointsB].reverse()
if (forwardCost + 1e-8 < reversedCost) return pointsB
return panelA.id > panelB.id ? [...pointsB].reverse() : pointsB
}

function orderedEdgeParticles(
panel: PatternDocument['panels'][string],
mesh: CompiledPanelSimMesh,
Expand Down Expand Up @@ -174,20 +150,6 @@ function projectPointToPolyline(points: Array<{ x: number; y: number }>, x: numb
return { t: bestT, distSq: bestDistSq }
}

function worldDistanceSq(
panelA: PatternDocument['panels'][string],
pointA: { x: number; y: number },
panelB: PatternDocument['panels'][string],
pointB: { x: number; y: number },
) {
const a = applyPlacementFromPattern(pointA.x, pointA.y, panelA)
const b = applyPlacementFromPattern(pointB.x, pointB.y, panelB)
const dx = a.x - b.x
const dy = a.y - b.y
const dz = a.z - b.z
return dx * dx + dy * dy + dz * dz
}

function particleDistance(
meshA: CompiledPanelSimMesh,
particleA: number,
Expand Down Expand Up @@ -233,61 +195,8 @@ function nearestParticle(
return best
}

function applyPlacementFromPattern(x: number, y: number, panel: PatternDocument['panels'][string]) {
const bounds = panelBounds(panel)
const worldX = ((x - bounds.minX) / bounds.width - 0.5) * bounds.width * PATTERN_UNIT_SCALE
const worldY = (0.5 - (y - bounds.minY) / bounds.height) * bounds.height * PATTERN_UNIT_SCALE
const q = quatFromEuler(panel.placement.rotation.x, panel.placement.rotation.y, panel.placement.rotation.z)
const rotated = rotateVec(worldX, worldY, 0, q.x, q.y, q.z, q.w)
return {
x: rotated.x + panel.placement.position.x,
y: rotated.y + panel.placement.position.y,
z: rotated.z + panel.placement.position.z,
}
}

function panelBounds(panel: PatternDocument['panels'][string]) {
const points = Object.values(panel.points)
if (!points.length) return { minX: -140, minY: -140, width: 280, height: 280 }
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const point of points) {
if (point.x < minX) minX = point.x
if (point.y < minY) minY = point.y
if (point.x > maxX) maxX = point.x
if (point.y > maxY) maxY = point.y
}
return { minX, minY, width: maxX - minX || 1, height: maxY - minY || 1 }
}

function quatFromEuler(x: number, y: number, z: number) {
const c1 = Math.cos(x / 2)
const s1 = Math.sin(x / 2)
const c2 = Math.cos(y / 2)
const s2 = Math.sin(y / 2)
const c3 = Math.cos(z / 2)
const s3 = Math.sin(z / 2)
return {
x: s1 * c2 * c3 + c1 * s2 * s3,
y: c1 * s2 * c3 - s1 * c2 * s3,
z: c1 * c2 * s3 + s1 * s2 * c3,
w: c1 * c2 * c3 - s1 * s2 * s3,
}
}

function rotateVec(x: number, y: number, z: number, qx: number, qy: number, qz: number, qw: number) {
const tx = 2 * (qy * z - qz * y)
const ty = 2 * (qz * x - qx * z)
const tz = 2 * (qx * y - qy * x)
return {
x: x + qw * tx + (qy * tz - qz * ty),
y: y + qw * ty + (qz * tx - qx * tz),
z: z + qw * tz + (qx * ty - qy * tx),
}
}

function clamp01(value: number) {
return value < 0 ? 0 : value > 1 ? 1 : value
return Math.min(1, Math.max(0, value))
}

22 changes: 16 additions & 6 deletions src/features/clothing/document/legacyAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ export function toPatternDocument(
const panels: Record<string, PatternPanel> = {}
const pieces = Object.values(garment.patterns)

for (const [index, piece] of pieces.entries()) {
const demoPlacements = buildDemoPlacementMap(pieces.map((piece) => piece.id))

for (const piece of pieces) {
panels[piece.id] = {
...JSON.parse(JSON.stringify(piece)),
placement: clonePlacement(placements[piece.id] ?? defaultPlacement(index, pieces.length)),
placement: clonePlacement(placements[piece.id] ?? demoPlacements[piece.id] ?? defaultPlacement(piece.id)),
pins: buildGluedEdgePins(piece),
metadata: undefined,
}
Expand Down Expand Up @@ -43,14 +45,22 @@ function clonePlacement(placement: PatternPlacement): PatternPlacement {
}
}

function defaultPlacement(index: number, count: number): PatternPlacement {
const spread = index - (count - 1) / 2
function defaultPlacement(_panelId: string): PatternPlacement {
return {
position: { x: spread * 0.18, y: 0.38, z: spread * -0.12 },
rotation: { x: 0, y: spread * 0.22, z: 0 },
position: { x: 0, y: 0.38, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
}
}

function buildDemoPlacementMap(panelIds: string[]): Record<string, PatternPlacement> {
const result: Record<string, PatternPlacement> = {}
if (panelIds.includes('torso-front')) result['torso-front'] = { position: { x: 0, y: 0.38, z: 0.26 }, rotation: { x: 0, y: 0, z: 0 } }
if (panelIds.includes('torso-back')) result['torso-back'] = { position: { x: 0, y: 0.38, z: -0.26 }, rotation: { x: 0, y: Math.PI, z: 0 } }
if (panelIds.includes('left-panel')) result['left-panel'] = { position: { x: -0.16, y: 0.38, z: 0 }, rotation: { x: 0, y: -Math.PI / 2, z: 0 } }
if (panelIds.includes('right-panel')) result['right-panel'] = { position: { x: 0.16, y: 0.38, z: 0 }, rotation: { x: 0, y: Math.PI / 2, z: 0 } }
return result
}

function buildGluedEdgePins(piece: GarmentDocument['patterns'][string]): PanelPin[] | undefined {
const glued = piece.gluedEdgeIds ?? []
if (glued.length === 0) return undefined
Expand Down
63 changes: 52 additions & 11 deletions src/features/clothing/geometry/seamUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { PatternDocument, PatternPanel, PatternSeam } from '../document/types'
import type { GarmentDocument, PatternPiece, Seam, Vec2 } from '../state/clothingTypes'
import { evaluateEdgeAt } from './patternSampling'

Expand All @@ -11,22 +12,53 @@ export type SeamSampleResult = {
pointsB: Vec2[]
}

export type ResolvedSeamSamples = {
pointsA: Vec2[]
pointsB: Vec2[]
reversedB: boolean
forwardCost: number
reversedCost: number
}

export function sampleSeam(doc: GarmentDocument, seam: Seam, samples = 8): SeamSampleResult | null {
const pieceA = doc.patterns[seam.a.patternId]
const pieceB = doc.patterns[seam.b.patternId]
if (!pieceA || !pieceB) return null
const resolved = resolveSeamSamples(doc, seam, samples)
if (!resolved) return null
return { seam, pointsA: resolved.pointsA, pointsB: resolved.pointsB }
}

const edgeA = pieceA.edges.find((e) => e.id === seam.a.edgeId)
const edgeB = pieceB.edges.find((e) => e.id === seam.b.edgeId)
if (!edgeA || !edgeB) return null
export function resolveSeamSamples(
doc: GarmentDocument | PatternDocument,
seam: Seam | PatternSeam,
samples: number,
): ResolvedSeamSamples | null {
const isGarment = 'patterns' in doc
const aId = 'patternId' in seam.a ? seam.a.patternId : seam.a.panelId
const bId = 'patternId' in seam.b ? seam.b.patternId : seam.b.panelId
const pieceA = isGarment ? doc.patterns[aId] : doc.panels[aId]
const pieceB = isGarment ? doc.patterns[bId] : doc.panels[bId]
if (!pieceA || !pieceB) return null

const ptsA = sampleEdgeById(pieceA, edgeA.id, samples, seam.a.reversed)
const ptsB = sampleEdgeById(pieceB, edgeB.id, samples, seam.b.reversed)
const pointsA = sampleEdgeById(pieceA, seam.a.edgeId, samples)
const authoredB = sampleEdgeById(pieceB, seam.b.edgeId, samples)
if (pointsA.length < 2 || authoredB.length < 2) {
return { pointsA, pointsB: authoredB, reversedB: false, forwardCost: 0, reversedCost: 0 }
}

return { seam, pointsA: ptsA, pointsB: ptsB }
const explicitA = seam.a.reversed ? [...pointsA].reverse() : pointsA
const explicitB = seam.b.reversed ? [...authoredB].reverse() : authoredB
const forwardCost = endpointCost(explicitA, explicitB, false)
const reversedCost = endpointCost(explicitA, explicitB, true)
const reverseByCost = reversedCost + 1e-8 < forwardCost
return {
pointsA: explicitA,
pointsB: reverseByCost ? [...explicitB].reverse() : explicitB,
reversedB: reverseByCost,
forwardCost,
reversedCost,
}
}

function sampleEdgeById(piece: PatternPiece, edgeId: string, samples: number, reversed?: boolean): Vec2[] {
function sampleEdgeById(piece: PatternPiece | PatternPanel, edgeId: string, samples: number): Vec2[] {
const edge = piece.edges.find((e) => e.id === edgeId)
if (!edge) return []

Expand All @@ -36,7 +68,16 @@ function sampleEdgeById(piece: PatternPiece, edgeId: string, samples: number, re
pts.push(evaluateEdgeAt(piece, edge, t))
}

return reversed ? pts.reverse() : pts
return pts
}

function endpointCost(pointsA: Vec2[], pointsB: Vec2[], reverseB: boolean) {
const bStart = reverseB ? pointsB[pointsB.length - 1] : pointsB[0]
const bEnd = reverseB ? pointsB[0] : pointsB[pointsB.length - 1]
const aStart = pointsA[0]
const aEnd = pointsA[pointsA.length - 1]
return Math.hypot(aStart.x - bStart.x, aStart.y - bStart.y)
+ Math.hypot(aEnd.x - bEnd.x, aEnd.y - bEnd.y)
}

// ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions src/features/clothing/pixi/PatternCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default function PatternCanvas() {

container.appendChild(app.canvas as HTMLCanvasElement)
const canvas = app.canvas as HTMLCanvasElement
canvas.style.touchAction = 'pan-y pinch-zoom'

// Scene graph
const world = new PIXI.Container()
Expand All @@ -55,6 +56,7 @@ export default function PatternCanvas() {
// Hover handling (kept here so it can run regardless of active tool)
// ---------------------------------------------------------------------
const onHoverMove = (e: PointerEvent) => {
if (e.pointerType === 'touch') return
const rect = canvas.getBoundingClientRect()
const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top }
const view = { w: canvas.clientWidth, h: canvas.clientHeight }
Expand Down
5 changes: 3 additions & 2 deletions src/features/clothing/pixi/interaction/CanvasController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ export class CanvasController {

private handlePointer(e: PointerEvent, kind: 'down' | 'move' | 'up') {
if (this.handleTouchGesture(e, kind)) return
if (e.pointerType === 'touch' && !this.shouldHandleSingleTouch(e, kind)) return
e.preventDefault()
const isTouch = e.pointerType === 'touch'
if (isTouch && !this.shouldHandleSingleTouch(e, kind)) return
if (!isTouch || this.touchDragPointerId === e.pointerId) e.preventDefault()
const view = this.viewSize()
const screen = this.screenOf(e)
const world = screenToWorld(screen, view.w, view.h)
Expand Down
12 changes: 0 additions & 12 deletions src/features/clothing/state/clothingActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,18 +395,6 @@ export function loadDemoGarment(doc: GarmentDocument) {
position: { x: 0, y: -0.56, z: -0.26 },
rotation: { x: 0, y: Math.PI, z: 0 },
}
if (patternIds.includes('pants-front')) {
clothingStore.placements['pants-front'] = {
position: { x: 0, y: -1.02, z: 0.2 },
rotation: { x: 0, y: 0, z: 0 },
}
}
if (patternIds.includes('pants-back')) {
clothingStore.placements['pants-back'] = {
position: { x: 0, y: -1.02, z: -0.2 },
rotation: { x: 0, y: Math.PI, z: 0 },
}
}
}
clothingStore.viewport2D.zoom = 1.35
clothingStore.viewport2D.panX = 0
Expand Down