UNPKG

@bitbybit-dev/core

Version:

Bit By Bit Developers Core CAD API to Program Geometry

546 lines (545 loc) 22.6 kB
import { Base } from "./inputs/base-inputs"; import { CACHE_CONFIG, DEFAULT_COLORS } from "./constants"; /** * Base class for DrawHelper implementations across all game engines. * Contains engine-agnostic utility methods that are shared between * PlayCanvas, ThreeJS, and BabylonJS implementations. */ export class DrawHelperCore { constructor(vector) { this.vector = vector; } /** * Compute the middle position of an edge * @param edge - Edge with vertex coordinates * @returns Middle point of the edge */ computeEdgeMiddlePos(edge) { let pos; if (edge.vertexCoord.length === 2) { const midFloor = edge.vertexCoord[0]; const midCeil = edge.vertexCoord[1]; pos = this.vector.lerp({ first: midFloor, second: midCeil, fraction: 0.5, }); } else if (edge.vertexCoord.length === 3) { pos = edge.vertexCoord[1]; } else { const midFloor = edge.vertexCoord[Math.floor(edge.vertexCoord.length / 2)]; const midCeil = edge.vertexCoord[Math.floor(edge.vertexCoord.length / 2 + 1)]; pos = this.vector.lerp({ first: midFloor, second: midCeil, fraction: 0.5, }); } return pos; } /** * Compute the center position of a face * @param vertexCoordVec - Array of vertex coordinates * @returns Center point of the face */ computeFaceMiddlePos(vertexCoordVec) { let x = 0; let y = 0; let z = 0; let realLength = 0; vertexCoordVec.forEach(v => { x += v[0]; y += v[1]; z += v[2]; realLength++; }); return [x / realLength, y / realLength, z / realLength]; } // ============== Color Utility Methods ============== /** * Resolve a color for a specific entity index based on the color mapping strategy * @param colors - Single color string or array of colors * @param entityIndex - Index of the entity that needs a color * @param totalEntities - Total number of entities being drawn * @param strategy - Color mapping strategy to use * @returns Resolved color for the entity */ resolveColorForEntity(colors, entityIndex, totalEntities, strategy = Base.colorMapStrategyEnum.lastColorRemainder) { // If single color or empty array, return it directly if (!Array.isArray(colors)) { return colors; } if (colors.length === 0) { return DEFAULT_COLORS.FACE; } if (colors.length === 1) { return colors[0]; } // If we have enough colors for all entities, use direct mapping if (colors.length >= totalEntities) { return colors[entityIndex]; } // Apply strategy when there are more entities than colors switch (strategy) { case Base.colorMapStrategyEnum.firstColorForAll: return colors[0]; case Base.colorMapStrategyEnum.lastColorRemainder: // Use corresponding color if available, otherwise use last color return entityIndex < colors.length ? colors[entityIndex] : colors[colors.length - 1]; case Base.colorMapStrategyEnum.repeatColors: // Cycle through colors in repeating pattern return colors[entityIndex % colors.length]; case Base.colorMapStrategyEnum.reversedColors: { // Ping-pong pattern: 0,1,2,1,0,1,2,1,0... const cycleLength = (colors.length - 1) * 2; if (cycleLength <= 0) { return colors[0]; } const position = entityIndex % cycleLength; if (position < colors.length) { return colors[position]; } else { return colors[cycleLength - position]; } } default: // Default to lastColorRemainder for safety return entityIndex < colors.length ? colors[entityIndex] : colors[colors.length - 1]; } } /** * Resolve all colors for a set of entities based on the color mapping strategy * @param colors - Single color string or array of colors * @param totalEntities - Total number of entities being drawn * @param strategy - Color mapping strategy to use * @returns Array of colors, one for each entity */ resolveAllColors(colors, totalEntities, strategy = Base.colorMapStrategyEnum.lastColorRemainder) { const result = []; for (let i = 0; i < totalEntities; i++) { result.push(this.resolveColorForEntity(colors, i, totalEntities, strategy)); } return result; } /** * Convert RGB values (0-255) to hex color string * @param r - Red component (0-255) * @param g - Green component (0-255) * @param b - Blue component (0-255) * @returns Hex color string (e.g., "#ff0000") */ colorToHex(r, g, b) { const toHex = (n) => { const hex = Math.round(n).toString(16); return hex.length === 1 ? "0" + hex : hex; }; return "#" + toHex(r) + toHex(g) + toHex(b); } /** * Convert normalized RGB values (0-1) to hex color string * @param r - Red component (0-1) * @param g - Green component (0-1) * @param b - Blue component (0-1) * @returns Hex color string (e.g., "#ff0000") */ normalizedColorToHex(r, g, b) { return this.colorToHex(r * 255, g * 255, b * 255); } /** * Convert hex color string to RGB object * @param hex - Hex color string (e.g., "#ff0000" or "ff0000") * @returns RGB object with values 0-1, or null if invalid */ hexToRgb(hex) { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); if (result) { return { r: parseInt(result[1], 16) / 255, g: parseInt(result[2], 16) / 255, b: parseInt(result[3], 16) / 255 }; } return null; } /** * Normalize color input to hex string with validation * @param color - Color as number array [r,g,b], hex string, or undefined * @param fallback - Fallback color if input is invalid * @returns Normalized hex color string */ normalizeColor(color, fallback) { if (!color) { return fallback; } if (Array.isArray(color)) { if (color.length < 3) { console.warn(`Invalid color array length: ${color.length}, expected at least 3. Using fallback: ${fallback}`); return fallback; } // Assume values are normalized (0-1) return this.normalizedColorToHex(color[0], color[1], color[2]); } if (typeof color === "string") { // Validate hex format if (!/^#?[0-9A-F]{6}$/i.test(color)) { console.warn(`Invalid hex color: ${color}. Using fallback: ${fallback}`); return fallback; } // Ensure it starts with # return color.startsWith("#") ? color : `#${color}`; } console.warn(`Unknown color format: ${typeof color}. Using fallback: ${fallback}`); return fallback; } // ============== Material Cache Utility Methods ============== /** * Generate a unique key for material caching * Uses fixed decimal precision to handle floating-point comparison * @param hex - Hex color string * @param alpha - Alpha value (0-1) * @param zOffset - Z-offset value for depth bias * @param unlit - Whether the material is unlit (no lighting) * @returns Unique cache key */ getMaterialKey(hex, alpha, zOffset, unlit = false) { const normalizedAlpha = alpha.toFixed(CACHE_CONFIG.ALPHA_PRECISION); const unlitSuffix = unlit ? "-unlit" : ""; return `${hex}-${normalizedAlpha}-${zOffset}${unlitSuffix}`; } // ============== Polyline Utility Methods ============== /** * Compute a signature string representing polyline structure * This is used to determine if existing geometry can be updated * @param polylinePoints - Array of polylines * @returns Signature string */ computePolylineSignature(polylinePoints) { return polylinePoints.map(line => line.length).join(","); } // ============== Arrow Computation Methods ============== /** * Compute arrow head lines for a polyline based on its last segment direction. * Creates 4 lines in 3D space forming an arrow head pointing in the direction of the polyline. * The arrow is constructed using two perpendicular planes through the direction vector. * * @param polylinePoints - Array of points forming the polyline [x,y,z][] * @param arrowSize - Length of the arrow head lines * @param arrowAngleDeg - Angle of the arrow head in degrees (from direction vector) * @returns Array of 4 line segments, each as [[startX, startY, startZ], [endX, endY, endZ]], or empty array if not enough points */ computeArrowHeadLines(polylinePoints, arrowSize, arrowAngleDeg) { if (polylinePoints.length < 2 || arrowSize <= 0) { return []; } // Get the last two points to determine direction const endPoint = polylinePoints[polylinePoints.length - 1]; const prevPoint = polylinePoints[polylinePoints.length - 2]; // Compute direction vector (from prev to end) const dx = endPoint[0] - prevPoint[0]; const dy = endPoint[1] - prevPoint[1]; const dz = endPoint[2] - prevPoint[2]; const length = Math.sqrt(dx * dx + dy * dy + dz * dz); if (length < 1e-10) { return []; } // Normalize direction const dirX = dx / length; const dirY = dy / length; const dirZ = dz / length; // Convert angle to radians const angleRad = (arrowAngleDeg * Math.PI) / 180; const cosAngle = Math.cos(angleRad); const sinAngle = Math.sin(angleRad); // Find a perpendicular vector using cross product with a reference vector // Choose reference vector that is not parallel to direction let refX = 0, refY = 1, refZ = 0; const dotWithY = Math.abs(dirY); if (dotWithY > 0.9) { // Direction is nearly parallel to Y, use X instead refX = 1; refY = 0; refZ = 0; } // Cross product: perp1 = dir × ref let perp1X = dirY * refZ - dirZ * refY; let perp1Y = dirZ * refX - dirX * refZ; let perp1Z = dirX * refY - dirY * refX; // Normalize perp1 const perp1Len = Math.sqrt(perp1X * perp1X + perp1Y * perp1Y + perp1Z * perp1Z); perp1X /= perp1Len; perp1Y /= perp1Len; perp1Z /= perp1Len; // Cross product: perp2 = dir × perp1 (second perpendicular) let perp2X = dirY * perp1Z - dirZ * perp1Y; let perp2Y = dirZ * perp1X - dirX * perp1Z; let perp2Z = dirX * perp1Y - dirY * perp1X; // Normalize perp2 const perp2Len = Math.sqrt(perp2X * perp2X + perp2Y * perp2Y + perp2Z * perp2Z); perp2X /= perp2Len; perp2Y /= perp2Len; perp2Z /= perp2Len; // Arrow head points: 4 points at the end of arrow lines // Each line goes from endPoint back along direction with perpendicular offset // The backward component: -dir * arrowSize * cos(angle) // The perpendicular component: perp * arrowSize * sin(angle) const backComponent = arrowSize * cosAngle; const perpComponent = arrowSize * sinAngle; // 4 arrow head endpoints using both perpendicular vectors const arrowLines = []; // Arrow line 1: +perp1 direction arrowLines.push([ endPoint, [ endPoint[0] - dirX * backComponent + perp1X * perpComponent, endPoint[1] - dirY * backComponent + perp1Y * perpComponent, endPoint[2] - dirZ * backComponent + perp1Z * perpComponent ] ]); // Arrow line 2: -perp1 direction arrowLines.push([ endPoint, [ endPoint[0] - dirX * backComponent - perp1X * perpComponent, endPoint[1] - dirY * backComponent - perp1Y * perpComponent, endPoint[2] - dirZ * backComponent - perp1Z * perpComponent ] ]); // Arrow line 3: +perp2 direction arrowLines.push([ endPoint, [ endPoint[0] - dirX * backComponent + perp2X * perpComponent, endPoint[1] - dirY * backComponent + perp2Y * perpComponent, endPoint[2] - dirZ * backComponent + perp2Z * perpComponent ] ]); // Arrow line 4: -perp2 direction arrowLines.push([ endPoint, [ endPoint[0] - dirX * backComponent - perp2X * perpComponent, endPoint[1] - dirY * backComponent - perp2Y * perpComponent, endPoint[2] - dirZ * backComponent - perp2Z * perpComponent ] ]); return arrowLines; } /** * Compute arrow head lines for multiple polylines * @param polylines - Array of polylines, each as array of points * @param arrowSize - Length of the arrow head lines * @param arrowAngleDeg - Angle of the arrow head in degrees * @returns Array of all arrow line segments from all polylines */ computeArrowHeadLinesForPolylines(polylines, arrowSize, arrowAngleDeg) { const allArrowLines = []; for (const polyline of polylines) { const arrowLines = this.computeArrowHeadLines(polyline, arrowSize, arrowAngleDeg); allArrowLines.push(...arrowLines); } return allArrowLines; } /** * Convert arrow lines to flat polyline format for rendering * Each arrow line is converted to a 2-point polyline (start and end) * @param arrowLines - Array of line segments [[start], [end]] * @returns Array of polylines suitable for drawing */ arrowLinesToPolylines(arrowLines) { return arrowLines; } // ============== Polyline Processing Methods ============== /** * Process polyline points, handling closed polylines by adding first point to end * @param polylines - Array of polylines * @returns Array of processed point arrays */ processPolylinePoints(polylines) { return polylines.map(polyline => { const points = polyline.points ? [...polyline.points] : []; // Don't mutate input if (polyline.isClosed && points.length > 0) { points.push(points[0]); } return points; }); } // ============== Normal Computation Methods ============== /** * Compute smooth vertex normals for a mesh when normals are not provided * Uses cross product of edge vectors and accumulates per-vertex * @param positions - Flat array of vertex positions [x,y,z,x,y,z,...] * @param indices - Triangle indices * @returns Flat array of normals [nx,ny,nz,nx,ny,nz,...] */ computeNormals(positions, indices) { const numVertices = positions.length / 3; const normals = new Float32Array(positions.length); // For each triangle, compute face normal and accumulate for (let i = 0; i < indices.length; i += 3) { const i0 = indices[i]; const i1 = indices[i + 1]; const i2 = indices[i + 2]; // Get vertices const v0x = positions[i0 * 3]; const v0y = positions[i0 * 3 + 1]; const v0z = positions[i0 * 3 + 2]; const v1x = positions[i1 * 3]; const v1y = positions[i1 * 3 + 1]; const v1z = positions[i1 * 3 + 2]; const v2x = positions[i2 * 3]; const v2y = positions[i2 * 3 + 1]; const v2z = positions[i2 * 3 + 2]; // Compute edge vectors const e1x = v1x - v0x; const e1y = v1y - v0y; const e1z = v1z - v0z; const e2x = v2x - v0x; const e2y = v2y - v0y; const e2z = v2z - v0z; // Cross product for face normal const nx = e1y * e2z - e1z * e2y; const ny = e1z * e2x - e1x * e2z; const nz = e1x * e2y - e1y * e2x; // Accumulate normals for each vertex normals[i0 * 3] += nx; normals[i0 * 3 + 1] += ny; normals[i0 * 3 + 2] += nz; normals[i1 * 3] += nx; normals[i1 * 3 + 1] += ny; normals[i1 * 3 + 2] += nz; normals[i2 * 3] += nx; normals[i2 * 3 + 1] += ny; normals[i2 * 3 + 2] += nz; } // Normalize all normals for (let i = 0; i < numVertices; i++) { const x = normals[i * 3]; const y = normals[i * 3 + 1]; const z = normals[i * 3 + 2]; const len = Math.sqrt(x * x + y * y + z * z); if (len > 0) { normals[i * 3] = x / len; normals[i * 3 + 1] = y / len; normals[i * 3 + 2] = z / len; } } return Array.from(normals); } /** * Expand indexed mesh to non-indexed (flat shaded) mesh with per-face normals * This creates unique vertices for each face, allowing flat shading * @param positions - Flat array of vertex positions [x,y,z,x,y,z,...] * @param indices - Triangle indices * @returns Object with expanded positions, new sequential indices, and flat normals */ expandToFlatShaded(positions, indices) { const expandedPositions = []; const expandedNormals = []; const expandedIndices = []; // For each triangle, create unique vertices with face normals for (let i = 0; i < indices.length; i += 3) { const i0 = indices[i]; const i1 = indices[i + 1]; const i2 = indices[i + 2]; // Get vertices const v0x = positions[i0 * 3]; const v0y = positions[i0 * 3 + 1]; const v0z = positions[i0 * 3 + 2]; const v1x = positions[i1 * 3]; const v1y = positions[i1 * 3 + 1]; const v1z = positions[i1 * 3 + 2]; const v2x = positions[i2 * 3]; const v2y = positions[i2 * 3 + 1]; const v2z = positions[i2 * 3 + 2]; // Compute edge vectors const e1x = v1x - v0x; const e1y = v1y - v0y; const e1z = v1z - v0z; const e2x = v2x - v0x; const e2y = v2y - v0y; const e2z = v2z - v0z; // Cross product for face normal let nx = e1y * e2z - e1z * e2y; let ny = e1z * e2x - e1x * e2z; let nz = e1x * e2y - e1y * e2x; // Normalize const len = Math.sqrt(nx * nx + ny * ny + nz * nz); if (len > 0) { nx /= len; ny /= len; nz /= len; } // Add expanded vertices (each triangle gets unique vertices) const baseIndex = expandedPositions.length / 3; expandedPositions.push(v0x, v0y, v0z); expandedPositions.push(v1x, v1y, v1z); expandedPositions.push(v2x, v2y, v2z); // Same normal for all three vertices (flat shading) expandedNormals.push(nx, ny, nz); expandedNormals.push(nx, ny, nz); expandedNormals.push(nx, ny, nz); // Sequential indices expandedIndices.push(baseIndex, baseIndex + 1, baseIndex + 2); } return { positions: expandedPositions, indices: expandedIndices, normals: expandedNormals }; } // ============== Back Face Mesh Data Preparation ============== /** * Prepare mesh data for back face rendering by flipping normals and reversing winding order * This is used to create a duplicate mesh that renders the back side with a different material * @param meshDataArray - Array of mesh data objects * @returns Combined mesh data with flipped normals and reversed indices */ prepareBackFaceMeshData(meshDataArray) { const totalPositions = []; let totalNormals = []; const totalIndices = []; const totalUvs = []; let indexOffset = 0; meshDataArray.forEach(meshItem => { totalPositions.push(...meshItem.positions); // Flip normals for back face if (meshItem.normals && meshItem.normals.length > 0) { for (let i = 0; i < meshItem.normals.length; i++) { totalNormals.push(-meshItem.normals[i]); } } if (meshItem.uvs) { totalUvs.push(...meshItem.uvs); } // Reverse winding order for back face (swap second and third vertex of each triangle) for (let i = 0; i < meshItem.indices.length; i += 3) { totalIndices.push(meshItem.indices[i] + indexOffset, meshItem.indices[i + 2] + indexOffset, // Swapped meshItem.indices[i + 1] + indexOffset // Swapped ); } indexOffset += meshItem.positions.length / 3; }); // Compute normals if they're missing if (totalNormals.length === 0 && totalPositions.length > 0) { const computedNormals = this.computeNormals(totalPositions, totalIndices); // Normals will already point in the correct direction due to reversed winding totalNormals = computedNormals; } return { positions: totalPositions, indices: totalIndices, normals: totalNormals, uvs: totalUvs.length > 0 ? totalUvs : undefined }; } /** * Get the default back face color * @returns Hex color string for back face */ getDefaultBackFaceColor() { return DEFAULT_COLORS.BACK_FACE; } }