From e9894ad1f09bb28cba5bbccf4808bdb338a1eeac Mon Sep 17 00:00:00 2001 From: robot <94223132+alightinastorm@users.noreply.github.com> Date: Thu, 21 May 2026 13:40:15 +0200 Subject: [PATCH] Unify seam sample resolution across renderer/compiler --- .../clothing/compiler/buildSeamConstraints.ts | 107 ++---------------- .../clothing/document/legacyAdapter.ts | 22 +++- src/features/clothing/geometry/seamUtils.ts | 63 +++++++++-- src/features/clothing/pixi/PatternCanvas.tsx | 2 + .../pixi/interaction/CanvasController.ts | 5 +- .../clothing/state/clothingActions.ts | 12 -- 6 files changed, 81 insertions(+), 130 deletions(-) diff --git a/src/features/clothing/compiler/buildSeamConstraints.ts b/src/features/clothing/compiler/buildSeamConstraints.ts index fc278c6..f66638f 100644 --- a/src/features/clothing/compiler/buildSeamConstraints.ts +++ b/src/features/clothing/compiler/buildSeamConstraints.ts @@ -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, @@ -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) @@ -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, @@ -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, @@ -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)) } + diff --git a/src/features/clothing/document/legacyAdapter.ts b/src/features/clothing/document/legacyAdapter.ts index 984a509..37c85ff 100644 --- a/src/features/clothing/document/legacyAdapter.ts +++ b/src/features/clothing/document/legacyAdapter.ts @@ -8,10 +8,12 @@ export function toPatternDocument( const panels: Record = {} 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, } @@ -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 { + const result: Record = {} + 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 diff --git a/src/features/clothing/geometry/seamUtils.ts b/src/features/clothing/geometry/seamUtils.ts index 996dd57..4f65bdc 100644 --- a/src/features/clothing/geometry/seamUtils.ts +++ b/src/features/clothing/geometry/seamUtils.ts @@ -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' @@ -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 [] @@ -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) } // --------------------------------------------------------------------------- diff --git a/src/features/clothing/pixi/PatternCanvas.tsx b/src/features/clothing/pixi/PatternCanvas.tsx index 4fbe465..5b57423 100644 --- a/src/features/clothing/pixi/PatternCanvas.tsx +++ b/src/features/clothing/pixi/PatternCanvas.tsx @@ -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() @@ -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 } diff --git a/src/features/clothing/pixi/interaction/CanvasController.ts b/src/features/clothing/pixi/interaction/CanvasController.ts index 9d02608..9abfd46 100644 --- a/src/features/clothing/pixi/interaction/CanvasController.ts +++ b/src/features/clothing/pixi/interaction/CanvasController.ts @@ -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) diff --git a/src/features/clothing/state/clothingActions.ts b/src/features/clothing/state/clothingActions.ts index 8f63031..183ed42 100644 --- a/src/features/clothing/state/clothingActions.ts +++ b/src/features/clothing/state/clothingActions.ts @@ -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