Files
cad-Integration/text-to-cad-main/packages/implicitjs/src/lib/implicitCad/model.js
T
2026-07-22 13:48:46 +08:00

615 lines
20 KiB
JavaScript

import {
normalizeParameterAnimations,
normalizeParameterDefinitions,
normalizeParameterValues,
parameterMapForDefinitions
} from "../../common/parameters.js";
import { createImplicitCadSdfEvaluator } from "./sdfEvaluator.js";
export {
IMPLICIT_CAD_EXTENSIONS,
IMPLICIT_CAD_KIND,
IMPLICIT_CAD_SCHEMA,
IMPLICIT_EXTENSIONS,
IMPLICIT_KIND,
IMPLICIT_SCHEMA
} from "./schema.js";
import {
IMPLICIT_CAD_EXTENSIONS,
IMPLICIT_CAD_KIND,
IMPLICIT_CAD_SCHEMA
} from "./schema.js";
const DEFAULT_BOUNDS = Object.freeze({
min: [-50, -50, -50],
max: [50, 50, 50]
});
const AUTO_BOUNDS_RADII = Object.freeze([4, 8, 16, 32, 64, 128, 256, 512]);
const AUTO_BOUNDS_SCAN_SAMPLES = 5;
const AUTO_BOUNDS_REFINE_SAMPLES = 9;
const AUTO_BOUNDS_CACHE_LIMIT = 32;
const DEFAULT_COLOR = "#f4f4f5";
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
const GLSL_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
const UNIFORM_TYPES = Object.freeze(new Set(["float", "int", "bool", "vec2", "vec3", "vec4"]));
const PARAM_UNIFORM_TYPES = Object.freeze({
number: "float",
boolean: "bool",
color: "vec3",
button: "int"
});
function isObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function finiteNumber(value, fallback = 0) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function finitePositiveNumber(value, fallback) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
}
function finiteVec3(value, fallback) {
const source = Array.isArray(value) || ArrayBuffer.isView(value) ? value : [];
return [0, 1, 2].map((index) => finiteNumber(source[index], fallback[index] || 0));
}
function finiteVector(value, length, fallback = []) {
const source = Array.isArray(value) || ArrayBuffer.isView(value) ? value : [];
return Array.from({ length }, (_, index) => finiteNumber(source[index], fallback[index] || 0));
}
function normalizeBounds(value, fallback = DEFAULT_BOUNDS) {
const radius = finitePositiveNumber(value, null);
const raw = radius
? { min: [-radius, -radius, -radius], max: [radius, radius, radius] }
: Array.isArray(value) && value.length >= 2
? { min: value[0], max: value[1] }
: isObject(value)
? value
: fallback;
const min = finiteVec3(raw.min, DEFAULT_BOUNDS.min);
const max = finiteVec3(raw.max, DEFAULT_BOUNDS.max);
for (let axis = 0; axis < 3; axis += 1) {
if (max[axis] <= min[axis]) {
const center = (min[axis] + max[axis]) / 2;
min[axis] = center - 0.5;
max[axis] = center + 0.5;
}
}
return { min, max };
}
function cloneBounds(bounds) {
return {
min: [...bounds.min],
max: [...bounds.max]
};
}
function hasExplicitBounds(value) {
if (value === undefined || value === null) {
return false;
}
if (String(value).trim().toLowerCase() === "auto") {
return false;
}
return !(isObject(value) && value.auto === true);
}
function boundsSize(bounds) {
return [
bounds.max[0] - bounds.min[0],
bounds.max[1] - bounds.min[1],
bounds.max[2] - bounds.min[2],
];
}
function expandBounds(min, max, margin) {
const safeMargin = Math.max(finiteNumber(margin, 0), 0.25);
return normalizeBounds({
min: [min[0] - safeMargin, min[1] - safeMargin, min[2] - safeMargin],
max: [max[0] + safeMargin, max[1] + safeMargin, max[2] + safeMargin]
});
}
function sampleAutoBounds(sdf, bounds, samples) {
const size = boundsSize(bounds);
const steps = size.map((axisSize) => axisSize / Math.max(samples - 1, 1));
const threshold = Math.max(Math.min(...steps) * 0.35, 0.002);
const hitMin = [Infinity, Infinity, Infinity];
const hitMax = [-Infinity, -Infinity, -Infinity];
let hitCount = 0;
let boundaryTouch = false;
for (let iz = 0; iz < samples; iz += 1) {
const z = bounds.min[2] + steps[2] * iz;
for (let iy = 0; iy < samples; iy += 1) {
const y = bounds.min[1] + steps[1] * iy;
for (let ix = 0; ix < samples; ix += 1) {
const x = bounds.min[0] + steps[0] * ix;
const value = finiteNumber(sdf(x, y, z), 1e6);
if (value > threshold) {
continue;
}
hitCount += 1;
hitMin[0] = Math.min(hitMin[0], x);
hitMin[1] = Math.min(hitMin[1], y);
hitMin[2] = Math.min(hitMin[2], z);
hitMax[0] = Math.max(hitMax[0], x);
hitMax[1] = Math.max(hitMax[1], y);
hitMax[2] = Math.max(hitMax[2], z);
if (value <= 0 && (
ix === 0 || iy === 0 || iz === 0 ||
ix === samples - 1 || iy === samples - 1 || iz === samples - 1
)) {
boundaryTouch = true;
}
}
}
}
return {
hitCount,
hitMin,
hitMax,
boundaryTouch,
step: steps,
threshold
};
}
function estimateAutoBoundsStartRadius(definition, params) {
const largest = definition.parameters
.filter((parameter) => parameter.type === "number")
.filter((parameter) => !likelyPoseParameter(parameter))
.reduce((maxValue, parameter) => {
const value = Math.abs(finiteNumber(params[parameter.id], 0));
return Math.max(maxValue, value);
}, 1);
const target = Math.max(largest * 3, 4);
return AUTO_BOUNDS_RADII.find((radius) => radius >= target) || AUTO_BOUNDS_RADII[AUTO_BOUNDS_RADII.length - 1];
}
function estimateImplicitCadAutoBounds(partialModel, definition, context) {
let sdf = null;
try {
sdf = createImplicitCadSdfEvaluator(partialModel);
} catch {
return cloneBounds(DEFAULT_BOUNDS);
}
let candidate = null;
const startRadius = estimateAutoBoundsStartRadius(definition, context.params);
for (const radius of AUTO_BOUNDS_RADII.filter((candidateRadius) => candidateRadius >= startRadius)) {
const bounds = normalizeBounds(radius);
const scan = sampleAutoBounds(sdf, bounds, AUTO_BOUNDS_SCAN_SAMPLES);
if (!scan.hitCount) {
continue;
}
const margin = Math.max(Math.max(...scan.step) * 1.5, radius * 0.06, 0.5);
candidate = expandBounds(scan.hitMin, scan.hitMax, margin);
if (!scan.boundaryTouch) {
break;
}
}
if (!candidate) {
return cloneBounds(DEFAULT_BOUNDS);
}
for (let pass = 0; pass < 1; pass += 1) {
const scan = sampleAutoBounds(sdf, candidate, AUTO_BOUNDS_REFINE_SAMPLES);
if (!scan.hitCount) {
break;
}
const margin = Math.max(Math.max(...scan.step) * 1.1, Math.max(...boundsSize(candidate)) * 0.025, 0.25);
candidate = expandBounds(scan.hitMin, scan.hitMax, margin);
}
return candidate;
}
function likelyPoseParameter(parameter) {
const unit = String(parameter?.unit || "").trim().toLowerCase();
if (unit === "deg" || unit === "rad") {
return true;
}
const text = `${parameter?.id || ""} ${parameter?.label || ""}`.toLowerCase();
return [
"angle",
"phase",
"rotation",
"rotate",
"orbit",
"spin",
"yaw",
"pitch",
"roll"
].some((token) => text.includes(token));
}
function autoBoundsCacheKey(definition, params, glslSource) {
const values = definition.parameters
.filter((parameter) => !likelyPoseParameter(parameter))
.filter((parameter) => PARAM_UNIFORM_TYPES[parameter.type])
.filter((parameter) => parameter.type !== "color")
.map((parameter) => {
const value = params[parameter.id];
if (typeof value === "number") {
return `${parameter.id}:${Number.isFinite(value) ? value.toPrecision(8) : "0"}`;
}
if (typeof value === "boolean") {
return `${parameter.id}:${value ? "1" : "0"}`;
}
return `${parameter.id}:${String(value ?? "")}`;
})
.join(";");
return `${glslSource.length}:${values}`;
}
function readCachedAutoBounds(cache, key, animationPlaying) {
if (!cache) {
return null;
}
if (cache.map.has(key)) {
return cloneBounds(cache.map.get(key));
}
if (animationPlaying && cache.last) {
return cloneBounds(cache.last);
}
return null;
}
function rememberAutoBounds(cache, key, bounds) {
if (!cache) {
return;
}
const cloned = cloneBounds(bounds);
cache.map.set(key, cloned);
cache.last = cloned;
while (cache.map.size > AUTO_BOUNDS_CACHE_LIMIT) {
const oldestKey = cache.map.keys().next().value;
cache.map.delete(oldestKey);
}
}
function resolveImplicitCadBounds(value, partialModel, definition, context, cache) {
if (hasExplicitBounds(value)) {
return {
bounds: normalizeBounds(value),
source: "explicit"
};
}
const key = autoBoundsCacheKey(definition, context.params, partialModel.glslSource);
const cached = readCachedAutoBounds(cache, key, context.animationState.playing);
if (cached) {
return {
bounds: cached,
source: "auto"
};
}
const estimated = estimateImplicitCadAutoBounds(partialModel, definition, context);
rememberAutoBounds(cache, key, estimated);
return {
bounds: estimated,
source: "auto"
};
}
function normalizeHexColor(value, fallback = DEFAULT_COLOR) {
const color = String(value || "").trim();
return HEX_COLOR_PATTERN.test(color) ? color : fallback;
}
function hexToRgb01(hex, fallback = DEFAULT_COLOR) {
const value = normalizeHexColor(hex, fallback);
const expanded = value.length === 4
? `${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`
: value.slice(1);
return [
parseInt(expanded.slice(0, 2), 16) / 255,
parseInt(expanded.slice(2, 4), 16) / 255,
parseInt(expanded.slice(4, 6), 16) / 255,
];
}
function evaluateValue(value, context) {
return typeof value === "function" ? value(context) : value;
}
function normalizeMaterial() {
const material = {};
return {
color: normalizeHexColor(material.color, DEFAULT_COLOR),
roughness: Math.min(Math.max(finiteNumber(material.roughness, 0.75), 0), 1),
metalness: Math.min(Math.max(finiteNumber(material.metalness, 0.02), 0), 1),
};
}
function unwrapModuleExports(moduleValue) {
if (typeof moduleValue === "function") {
return moduleValue();
}
if (!isObject(moduleValue)) {
return moduleValue;
}
if (moduleValue.default !== undefined) {
return typeof moduleValue.default === "function" ? moduleValue.default() : moduleValue.default;
}
if (moduleValue.model !== undefined) {
return typeof moduleValue.model === "function" ? moduleValue.model() : moduleValue.model;
}
return moduleValue;
}
function normalizeUniformValue(rawValue, context = {}) {
const raw = evaluateValue(rawValue, context);
const value = isObject(raw) && Object.hasOwn(raw, "value")
? evaluateValue(raw.value, context)
: raw;
const explicitType = isObject(raw) ? String(raw.type || raw.kind || "").trim().toLowerCase() : "";
const type = UNIFORM_TYPES.has(explicitType)
? explicitType
: typeof value === "boolean"
? "bool"
: typeof value === "number"
? "float"
: typeof value === "string" && HEX_COLOR_PATTERN.test(value)
? "vec3"
: Array.isArray(value) || ArrayBuffer.isView(value)
? `vec${Math.min(Math.max(value.length, 2), 4)}`
: "";
if (!UNIFORM_TYPES.has(type)) {
return null;
}
if (type === "bool") {
return { type, value: value === true };
}
if (type === "int") {
return { type, value: Math.trunc(finiteNumber(value, 0)) };
}
if (type === "float") {
return { type, value: finiteNumber(value, 0) };
}
if (typeof value === "string" && HEX_COLOR_PATTERN.test(value)) {
return { type, value: hexToRgb01(value) };
}
const length = Number(type.slice(3));
const fallback = Array.from({ length }, () => 0);
return {
type,
value: finiteVector(value, length, fallback)
};
}
function uniformsFromParams(parameters = [], params = {}) {
return Object.fromEntries(
parameters
.map((parameter) => {
const uniformName = String(parameter?.id || "").trim();
if (!GLSL_IDENTIFIER_PATTERN.test(uniformName) || uniformName.startsWith("gl_")) {
throw new Error(`Implicit CAD param "${uniformName}" is not a valid GLSL uniform identifier.`);
}
const type = PARAM_UNIFORM_TYPES[parameter.type];
if (!type) {
return null;
}
const uniform = normalizeUniformValue({ type, value: params[parameter.id] });
return uniform ? [uniformName, uniform] : null;
})
.filter(Boolean)
);
}
function glslSourceFromModel(model, context = {}) {
return String(evaluateValue(model.glsl ?? model.glslSource, context) || "").trim();
}
export function pathIsImplicitCadSource(value = "") {
const pathname = String(value || "").split(/[?#]/, 1)[0].toLowerCase();
return IMPLICIT_CAD_EXTENSIONS.some((extension) => pathname.endsWith(extension));
}
function normalizeAnimationState(value = {}) {
const activeId = String(value?.activeId || "").trim();
return {
activeId,
playing: value?.playing === true,
elapsedSec: Math.max(finiteNumber(value?.elapsedSec ?? value?.elapsed, 0), 0),
speed: Math.min(Math.max(finiteNumber(value?.speed, 1), 0.1), 5)
};
}
function findAnimation(definition, animationId) {
const animations = Array.isArray(definition?.animations) ? definition.animations : [];
if (!animations.length) {
return null;
}
const normalizedId = String(animationId || "").trim();
return animations.find((animation) => animation.id === normalizedId) || animations[0] || null;
}
function buildImplicitCadContext(definition, parameterValues = {}, animationState = {}) {
const params = normalizeParameterValues(definition, parameterValues);
const normalizedAnimationState = normalizeAnimationState(animationState);
const animation = findAnimation(definition, normalizedAnimationState.activeId);
const duration = Math.max(Number(animation?.duration) || 0, 0.001);
const elapsedSec = Math.min(normalizedAnimationState.elapsedSec, duration);
const progress = animation ? Math.min(Math.max(elapsedSec / duration, 0), 1) : 0;
return {
...params,
params,
parameterValues: params,
animation,
animationState: {
...normalizedAnimationState,
activeId: animation?.id || normalizedAnimationState.activeId,
duration: animation?.duration || 0,
loop: animation?.loop !== false
},
elapsed: elapsedSec,
elapsedSec,
duration: animation?.duration || 0,
progress,
cycle: duration > 0 ? elapsedSec / duration : 0,
t: elapsedSec,
time: elapsedSec
};
}
function normalizeImplicitCadRuntimeModel(rawModel, definition, parameterValues = {}, animationState = {}, { sourceUrl = "", autoBoundsCache = null } = {}) {
const context = buildImplicitCadContext(definition, parameterValues, animationState);
const model = rawModel;
if (!isObject(model)) {
throw new Error("Implicit CAD module must export an object, or a function returning an object.");
}
const uniforms = uniformsFromParams(definition.parameters, context.params);
const glslSource = glslSourceFromModel(model, context);
if (!glslSource) {
throw new Error("Implicit CAD module must export GLSL code in glsl.");
}
const resolvedBounds = resolveImplicitCadBounds(
evaluateValue(model.bounds, context),
{ glslSource, uniforms },
definition,
context,
autoBoundsCache
);
const bounds = resolvedBounds.bounds;
const size = [
bounds.max[0] - bounds.min[0],
bounds.max[1] - bounds.min[1],
bounds.max[2] - bounds.min[2],
];
const center = [
(bounds.min[0] + bounds.max[0]) / 2,
(bounds.min[1] + bounds.max[1]) / 2,
(bounds.min[2] + bounds.max[2]) / 2,
];
const radius = Math.max(Math.hypot(size[0], size[1], size[2]) / 2, 1e-3);
const evaluatedRender = evaluateValue(model.render, context);
const render = isObject(evaluatedRender) ? evaluatedRender : {};
const epsilon = finitePositiveNumber(evaluateValue(render.epsilon, context), Math.max(radius * 0.0007, 0.002));
const normalEpsilon = finitePositiveNumber(
evaluateValue(render.normalEpsilon, context),
Math.max(epsilon * 2.5, radius * 0.001)
);
const stepScale = Math.min(
Math.max(finiteNumber(evaluateValue(render.stepScale, context), 0.45), 0.05),
1
);
const maxStep = finitePositiveNumber(
evaluateValue(render.maxStep, context),
Math.max(radius * 0.035, epsilon * 10)
);
return {
schema: String(model.schema || IMPLICIT_CAD_SCHEMA),
kind: IMPLICIT_CAD_KIND,
name: String(model.name || model.title || "Implicit CAD").trim() || "Implicit CAD",
description: String(model.description || "").trim(),
units: String(model.units || "mm").trim() || "mm",
sourceUrl: String(sourceUrl || model.sourceUrl || "").trim(),
parameters: definition.parameters,
parameterMap: definition.parameterMap,
defaultParameterValues: definition.defaultParameterValues,
parameterValues: context.params,
animations: definition.animations,
animationState: context.animationState,
glslSource,
distanceSource: glslSource,
colorSource: /\bvec3\s+color\s*\(/.test(glslSource) ? glslSource : "",
uniforms,
uniformSignature: Object.entries(uniforms)
.map(([name, uniform]) => `${name}:${uniform.type}`)
.sort()
.join(";"),
bounds,
boundsSource: resolvedBounds.source,
center,
size,
radius,
material: normalizeMaterial(),
background: {},
maxSteps: Math.max(16, Math.min(Math.floor(finiteNumber(evaluateValue(render.steps, context), 192)), 768)),
maxDistance: finitePositiveNumber(evaluateValue(render.maxDistance, context), radius * 8),
stepScale,
maxStep,
epsilon,
normalEpsilon,
};
}
export function normalizeImplicitCadDefinition(moduleValue, { sourceUrl = "" } = {}) {
const model = unwrapModuleExports(moduleValue);
if (!isObject(model)) {
throw new Error("Implicit CAD module must export an object, or a function returning an object.");
}
if (model.kind === IMPLICIT_CAD_KIND && model.glslSource && Array.isArray(model.parameters)) {
return model.definition ? model : { ...model, definition: null };
}
const parameters = normalizeParameterDefinitions(model.params);
const parameterMap = parameterMapForDefinitions(parameters);
const autoBoundsCache = {
map: new Map(),
last: null
};
const definition = {
schema: String(model.schema || IMPLICIT_CAD_SCHEMA),
kind: IMPLICIT_CAD_KIND,
name: String(model.name || model.title || "Implicit CAD").trim() || "Implicit CAD",
description: String(model.description || "").trim(),
units: String(model.units || "mm").trim() || "mm",
sourceUrl: String(sourceUrl || model.sourceUrl || "").trim(),
parameters,
parameterMap,
defaultParameterValues: {},
animations: normalizeParameterAnimations(model.animations),
buildModel(parameterValues = {}, animationState = {}) {
return normalizeImplicitCadRuntimeModel(model, definition, parameterValues, animationState, { sourceUrl, autoBoundsCache });
}
};
definition.defaultParameterValues = normalizeParameterValues(
definition,
model.values || {}
);
const defaultModel = definition.buildModel(definition.defaultParameterValues);
return {
...defaultModel,
definition
};
}
export function normalizeImplicitCadModel(moduleValue, { sourceUrl = "" } = {}) {
return normalizeImplicitCadDefinition(moduleValue, { sourceUrl });
}
export function implicitCadBoundsCenterAndRadius(model) {
let normalized = null;
try {
normalized = isObject(model) || typeof model === "function" ? normalizeImplicitCadModel(model) : null;
} catch {
normalized = null;
}
if (normalized) {
return {
center: normalized.center,
radius: normalized.radius,
bounds: normalized.bounds,
};
}
return {
center: [0, 0, 0],
radius: 1,
bounds: { ...DEFAULT_BOUNDS },
};
}
export const pathIsImplicitSource = pathIsImplicitCadSource;
export const normalizeImplicitDefinition = normalizeImplicitCadDefinition;
export const normalizeImplicitModel = normalizeImplicitCadModel;
export const implicitBoundsCenterAndRadius = implicitCadBoundsCenterAndRadius;