Support three.js meshes and materials in static builds

- Compile mesh construction, transforms, cloning, and material updates for native and WASI targets.
- Preserve subclass dispatch, fluent returns, and shared array identity in JavaScript packages.
- Support native class arrays and recursive conversions without materializing classes for type checks.
This commit is contained in:
Chris Tate
2026-09-29 23:34:18 -05:00
committed by GitHub
parent df78e89342
commit 05021faf2f
24 changed files with 460 additions and 62 deletions
@@ -4679,14 +4679,12 @@ export class LlEmitter {
throw new InternalCompilerError(`llvm emitter bug: live dyn ref of ${key}`);
}
const prefix = `sc_ldr_${this.liveDynRefAdapters.size}`;
const adapter = streamTypedRefMaterializeAdapter(
return streamTypedRefMaterializeAdapter(
this,
t,
{ prefix, adapters: new Map() },
{ prefix, adapters: this.liveDynRefAdapters },
`${prefix}_materialize`,
);
this.liveDynRefAdapters.set(key, adapter);
return adapter;
}
liveDynUnionRefAdapter(
@@ -48,16 +48,7 @@ export function emitDynamicExpr(host: LlvmEmitterContext, e: ExprOf<"dynFrom" |
throw new InternalCompilerError(`llvm emitter bug: live dyn ref of ${typeKey(v.type)}`);
}
const key = typeKey(v.type);
let adapter = host.liveDynRefAdapters.get(key);
if (!adapter) {
const prefix = `sc_ldr_${host.liveDynRefAdapters.size}`;
adapter = host.streamTypedRefMaterializeAdapter(
v.type,
{ prefix, adapters: new Map() },
`${prefix}_materialize`,
);
host.liveDynRefAdapters.set(key, adapter);
}
const adapter = host.liveDynRefAdapter(v.type);
const rc = vAdapters(host.shapeHost, v.type);
const boxed = B.tmp();
@@ -559,6 +559,9 @@ export function streamTypedRefMaterializeAdapter(host: LlvmEmitterContext,
return adapter;
}
}
// Publish both symbols before their bodies: class-array and callback
// converters can request this same adapter while emitting the commit.
adapter.commit = `@${snapshot}_commit`;
adapter.commit = host.streamTypedRefCommitAdapter(t, snapshot);
const B = new BlockBuilder();
@@ -16,6 +16,12 @@ interface Dispatch {
classes: Set<string>;
}
interface PropertyReceiver {
info: ClassInfo;
capsule: ClassInfo;
key: string;
}
interface PropertyDispatch {
name: string;
write: boolean;
@@ -316,16 +322,18 @@ export class ClassDynamicDispatch {
}
}
for (const dispatch of this.properties.values()) {
for (const info of this.propertyCandidates(lowerer, dispatch.name)) {
if (dispatch.classes.has(info.def.name)) continue;
dispatch.classes.add(info.def.name);
for (const plan of this.propertyReceivers(lowerer)) {
const { info } = plan;
if (!isClassOwnEnumerableFieldName(dispatch.name) ||
!info.fields.has(dispatch.name) && !findMethodOn(lowerer, info, `get:${dispatch.name}`) && !findMethodOn(lowerer, info, `set:${dispatch.name}`)) continue;
if (dispatch.classes.has(plan.key)) continue;
dispatch.classes.add(plan.key);
const loc = dispatch.fn.loc;
const type: IrType = { kind: "object", className: info.def.name };
const receiver = varRef("p.0", DYN, loc);
const { receiver, condition } = this.propertyReceiver(plan, loc);
const before = lowerer.diags.length;
let branch: IrStmt[];
try {
branch = this.propertyBody(lowerer, dispatch, info, { kind: "dynCheck", value: receiver, type, loc });
branch = this.propertyBody(lowerer, dispatch, info, receiver);
} catch (error) {
if (!(error instanceof PoisonError) || !info.decl) throw error;
const fence = lowerer.deferToRuntimeFence(before, info.decl, { kind: "statement" });
@@ -333,23 +341,18 @@ export class ClassDynamicDispatch {
branch = [fence];
}
dispatch.fn.body.unshift({
kind: "if", cond: {
kind: "libCall", fn: "dyn.typedRefIs", args: [receiver, { kind: "strLit", value: typeKey(type), type: STRING, loc }], type: BOOL, loc,
}, then: branch, else_: null, loc,
kind: "if", cond: condition, then: branch, else_: null, loc,
});
changed = true;
}
}
for (const dispatch of this.computed.values()) {
for (const className of this.boxed) {
if (dispatch.classes.has(className)) continue;
const info = lowerer.classes.get(className);
if (!info || info.builtinEmitter || info.builtinStream || info.builtinError) continue;
dispatch.classes.add(className);
for (const plan of this.propertyReceivers(lowerer)) {
if (dispatch.classes.has(plan.key)) continue;
const { info } = plan;
dispatch.classes.add(plan.key);
const loc = dispatch.fn.loc;
const type: IrType = { kind: "object", className };
const value = varRef("p.0", DYN, loc);
const receiver: IrExpr = { kind: "dynCheck", value, type, loc };
const { receiver, condition } = this.propertyReceiver(plan, loc);
const names = new Set([...info.fields.keys()].filter(isClassOwnEnumerableFieldName));
for (let owner: ClassInfo | null = info; owner; owner = owner.base) {
for (const method of owner.methods.keys()) {
@@ -372,9 +375,7 @@ export class ClassDynamicDispatch {
kind: "strEq", left: varRef(dispatch.keyLocal, STRING, loc), right: { kind: "strLit", value: name, type: STRING, loc }, negated: false, type: BOOL, loc,
}, then: body, else_: null, loc });
}
dispatch.fn.body.splice(dispatch.branchIndex, 0, { kind: "if", cond: {
kind: "libCall", fn: "dyn.typedRefIs", args: [value, { kind: "strLit", value: typeKey(type), type: STRING, loc }], type: BOOL, loc,
}, then: branch, else_: null, loc });
dispatch.fn.body.splice(dispatch.branchIndex, 0, { kind: "if", cond: condition, then: branch, else_: null, loc });
changed = true;
}
}
@@ -400,13 +401,34 @@ export class ClassDynamicDispatch {
add(root);
}
private propertyCandidates(lowerer: Lowerer, name: string): ClassInfo[] {
if (!isClassOwnEnumerableFieldName(name)) return [];
return [...this.boxed].flatMap((className) => {
const info = lowerer.classes.get(className);
if (!info || info.builtinEmitter || info.builtinStream || info.builtinError) return [];
return info.fields.has(name) || findMethodOn(lowerer, info, `get:${name}`) || findMethodOn(lowerer, info, `set:${name}`) ? [info] : [];
});
/** A capsule retains its static type, while its object may be a subclass.
* Preorder insertion lets later branches test the most derived layout first. */
private propertyReceivers(lowerer: Lowerer): PropertyReceiver[] {
const plans: PropertyReceiver[] = [];
for (const name of this.boxed) {
const capsule = lowerer.classes.get(name);
if (!capsule || capsule.builtinEmitter || capsule.builtinStream || capsule.builtinError) continue;
const visit = (info: ClassInfo): void => {
plans.push({ info, capsule, key: JSON.stringify([name, info.def.name]) });
for (const child of info.subclasses) visit(child);
};
visit(capsule);
}
return plans;
}
private propertyReceiver(plan: PropertyReceiver, loc: SrcLoc): { receiver: IrExpr; condition: IrExpr } {
const value = varRef("p.0", DYN, loc);
const type: IrType = { kind: "object", className: plan.capsule.def.name };
const checked: IrExpr = { kind: "dynCheck", value, type, loc };
const matches: IrExpr = { kind: "libCall", fn: "dyn.typedRefIs", args: [value,
{ kind: "strLit", value: typeKey(type), type: STRING, loc }], type: BOOL, loc };
if (plan.info === plan.capsule) return { receiver: checked, condition: matches };
return {
receiver: { kind: "downcast", value: checked, type: { kind: "object", className: plan.info.def.name }, loc },
condition: { kind: "ternary", cond: matches, then: { kind: "instanceOf", value: checked,
className: plan.info.def.name, type: BOOL, loc }, else_: { kind: "boolLit", value: false, type: BOOL, loc }, type: BOOL, loc },
};
}
private propertyBody(lowerer: Lowerer, dispatch: PropertyDispatch, info: ClassInfo, receiver: IrExpr): IrStmt[] {
@@ -325,6 +325,11 @@ export function lowerDynObjectLiteral(
let v = boxValue
? boxValue(valueExpr as ts.Expression, raw)
: lowerer.coerceToExpected(raw, DYN);
// A JS object's array-valued properties retain the same source array.
// Repeated references must compare equal and mutations remain shared.
if (!boxValue && isJsSourceFile(prop.getSourceFile()) && v.kind === "dynFrom" && v.value.type.kind === "array") {
v = { ...v, liveRef: true };
}
if (v.type.kind !== "dyn") {
const convDiagsBefore = lowerer.diags.length;
try {
@@ -26,7 +26,7 @@ import { lowerAbsenceProbe, lowerPromiseAllTupleCall, lowerPromiseRejectCall, st
import { isSafeToDiscard } from "./expressions/evaluation-safety.js";
import { tryLowerExpression } from "./expressions/try-lower-expression.js";
import { httpClientFnBindingOf, isStreamUndefCallExpr, lowerCompatReqStreamOptionalCall, lowerHttpClientFnCall } from "./lower-server.js";
import { EMITTER_API_MEMBERS, exactInstanceClassOf, findGenericMethodOn, lowerClassGenericMethodCall, lowerStaticMethodCall, type ClassInfo } from "./lower-classes.js";
import { EMITTER_API_MEMBERS, exactInstanceClassOf, findGenericMethodOn, lowerClassGenericMethodCall, lowerStaticMethodCall, returnsOnlyThis, type ClassInfo } from "./lower-classes.js";
import { classCallbackCall, isClassCallback } from "./class-callbacks.js";
import { emitterRooted, lowerEmitterMethodCall } from "./lower-event-emitter.js";
import { lowerConsoleInspectArg, lowerFormatCall } from "./lower-inspect.js";
@@ -6768,6 +6768,12 @@ const inliningPredicates = new Set<ts.Symbol>();
function reconcileOverloadReturn(lowerer: Lowerer, expr: ts.CallExpression | ts.TaggedTemplateExpression, call: IrExpr): IrExpr {
const rsig = lowerer.checker.getResolvedSignature(expr);
const rdecl = rsig ? lowerer.checker.signatureDeclaration(rsig) : undefined;
// Fluent JS overrides share the base's return ABI, but their result
// still has the receiver's subclass layout at the call site.
if (call.type.kind === "object" && rdecl && ts.isMethodDeclaration(rdecl) &&
isJsSourceFile(rdecl.getSourceFile()) && returnsOnlyThis(rdecl)) {
call = lowerer.maybeNarrow(call, expr);
}
const resolvedOverload =
rsig && rdecl &&
(ts.isFunctionDeclaration(rdecl) || ts.isMethodDeclaration(rdecl)) &&
@@ -30,6 +30,24 @@ import { isNativeProxyInitializer, lowerNativeProxy } from "./expressions/native
import { classStaticDataFor } from "./class-static-data.js";
import { lowerInstanceConstructorNew } from "./class-instance-constructor.js";
export function returnsOnlyThis(member: ts.MethodDeclaration): boolean {
const body = member.body;
const last = body?.statements.at(-1);
if (!body || !last || !ts.isReturnStatement(last)) return false;
let valid = true;
const visit = (node: ts.Node): void => {
if (ts.isFunctionLike(node)) return;
if (ts.isReturnStatement(node)) {
let value = node.expression;
while (value && ts.isParenthesizedExpression(value)) value = value.expression;
if (!value || value.kind !== ts.SyntaxKind.ThisKeyword) valid = false;
}
node.forEachChild(visit);
};
body.forEachChild(visit);
return valid;
}
function functionLocalClass(decl: ts.ClassLikeDeclaration): boolean {
if (!ts.isClassExpression(decl)) return false;
for (let parent: ts.Node | undefined = decl.parent; parent && !ts.isSourceFile(parent); parent = parent.parent) {
@@ -545,13 +563,16 @@ export interface GenericClassInfo {
/** The undefined-armed union of a JS class property's inferred type — the
* honest slot for a field first assigned outside the constructor's top
* level (undefined until the write runs, Node-exact). Null when the
* inference is unmappable, checked-dynamic (dyn stays out of class
* fields — KEEP NARROW), or an arm-less kind that cannot join a union
* inference is unmappable or an arm-less kind that cannot join a union
* (genResultRecord's list, including scalar-backed Date values). */
function undefArmedFieldType(lowerer: Lowerer, p: ts.Symbol): IrType | null {
const t = lowerer.checker.getTypeOfSymbol(p);
const mapped = lowerer.mapTypeOf(t);
if (!mapped || mapped.kind === "void" || mapped.kind === "dyn") return null;
const site = lowerer.checker.declarationsOf(p)[0];
let mapped = lowerer.mapTypeOf(t) ?? (site ? dynFallbackType(lowerer, site, t) : null);
if (mapped && site) mapped = jsOpenObjectType(site, mapped, lowerer.shapes, lowerer.unions);
if (!mapped || mapped.kind === "void") return null;
// An implicit JS value already carries undefined in its native box.
if (mapped.kind === "dyn") return mapped;
const byKey = new Map<string, IrType>();
const arms = mapped.kind === "union" ? (lowerer.unions.get(mapped.unionId)?.arms ?? []) : [mapped];
for (const a of arms) {
@@ -1911,6 +1932,11 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla
);
}
if (overridden && isJsSourceFile(member.getSourceFile())) {
// Fluent overrides return the same native receiver. Keep the
// inherited return ABI and upcast `this` when lowering returns.
if (overridden.sig.ret.kind === "object" && base &&
(base.def.name === overridden.sig.ret.className || lowerer.isSubclassOf(base.def.name, overridden.sig.ret.className)) &&
returnsOnlyThis(member)) ft.ret = overridden.sig.ret;
// An unannotated JS parameter can use the base's typed ABI.
// Defaults stay local to each declaration, so only inherited
// required/optional slots participate in this refinement.
@@ -2386,6 +2412,10 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla
if (isJsSourceFile(decl.getSourceFile())) {
const armed = undefArmedFieldType(lowerer, p);
if (armed !== null) {
// Unknown JS properties use the instance bag: they do not
// exist until assigned, and a subclass may declare the same
// name as an ordinary layout field.
if (armed.kind === "dyn") continue;
fields.set(p.name, armed);
fieldOrder.push({ name: p.name, type: armed, initializer: undefined });
continue;
@@ -2163,8 +2163,12 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
`reading the abstract property '${expr.name.text}' through a '${lowerer.checker.typeToString(lowerer.typeOf(expr.expression))}'-typed receiver (abstract property declarations are erased at runtime, so no shared slot exists — type the receiver as the concrete class, or declare an abstract getter instead)`,
);
}
if (isDynTypedRefType(recvLowered.type)) {
const info = lowerer.classes.get(recvLowered.type.className);
// Array reads may add an undefined arm even when the checker still
// reports a class. Its property bag must remain reachable, while
// an absent element takes the normal checked-value TypeError.
const nativeReceiver = lowerer.stripUndefinedArm(recvLowered.type);
if (isDynTypedRefType(nativeReceiver)) {
const info = lowerer.classes.get(nativeReceiver.className);
if (info && !info.def.runtime && !info.builtinError && !info.builtinEmitter && !info.builtinStream) {
return lowerer.maybeNarrow({
kind: "dynKeyGet", value: lowerer.coerceToExpected(recvLowered, DYN),
@@ -4679,7 +4683,7 @@ export function lowerOptionalNumber(
// (`pkg.workspaces.packages["0"]` — the lowering world types the
// unknown-rooted chain `any`); a non-dyn lowering falls through to
// the fences below (re-lowering is pure IR construction).
if (receiverIr?.kind === "dyn" || receiverIr === null) {
if (receiverIr?.kind === "dyn" || receiverIr?.kind === "object" || receiverIr?.kind === "union" || receiverIr === null) {
const obj = lowerer.lowerExpr(expr.expression);
// A generic mapped type can stay unresolved at this body use even
// though its instantiated parameter has a concrete record ABI.
@@ -4690,7 +4694,7 @@ export function lowerOptionalNumber(
return lowerer.lowerRecordKeyRead(expr, obj.type.shapeId, shape);
}
}
if (obj.type.kind === "dyn") {
if (obj.type.kind === "dyn" || isDynTypedRefType(obj.type)) {
const rawKey = lowerer.lowerExpr(expr.argumentExpression);
// Number, bool, and DYN keys stringify (ToPropertyKey) — the
// dyn-keyed read `catchWarning[warning.name]` where the property
@@ -4699,7 +4703,7 @@ export function lowerOptionalNumber(
if (key.type.kind === "string") {
const opt = hasOptionalChainGuard(expr.expression);
return lowerer.maybeNarrow(
{ kind: "dynKeyGet", key, ...(opt ? { optional: true as const } : {}), value: obj, type: DYN, loc: locOf(expr) },
{ kind: "dynKeyGet", key, ...(opt ? { optional: true as const } : {}), value: lowerer.coerceToExpected(obj, DYN), type: DYN, loc: locOf(expr) },
expr,
);
}
@@ -9913,6 +9917,9 @@ function representedClassFieldTarget(
if (receiverIr?.kind === "object") {
return classFieldTarget(lowerer, access.expression, receiverIr, access.name.text);
}
if (receiverIr?.kind === "union" && hasClassPayload(lowerer, receiverIr)) {
return representedClassFieldTarget(lowerer, access.expression, access.name.text, lowerer.lowerExpr(access.expression));
}
if (receiverIr?.kind === "record") {
const shape = lowerer.shapes.get(receiverIr.shapeId);
const fieldType = shape?.fields.find((f) => f.name === access.name.text)?.type;
@@ -39,7 +39,7 @@ import { isNativeProxyInitializer } from "./expressions/native-proxy.js";
import { tryLowerExpression } from "./expressions/try-lower-expression.js";
import { lowerUnionFieldWrite } from "./expressions/union-field-write.js";
import { UNSUPPORTED, checkerPanicDiag, isCheckerPanic, requiresDynamicDiag } from "../../diagnostics/diagnostic.js";
import { isParseArgsDynTypeName, isUnitOnlyTsType, unitOnlyUnion } from "../type-mapper.js";
import { isParseArgsDynTypeName, isUnitOnlyTsType, jsOpenObjectType, unitOnlyUnion } from "../type-mapper.js";
import { canonicalBuiltinModule } from "../builtin-modules.js";
import { isRelativeSpecifier } from "../workspace-registry.js";
import { probeNodeRequireRefusal } from "../npm.js";
@@ -3830,7 +3830,8 @@ export function lowerVarDecl(lowerer: Lowerer, decl: ts.VariableDeclaration, isL
}
const preservesObjectIdentity = init.type.kind === "dyn" &&
(inferredObjectType?.kind === "record" ||
(isJsSourceFile(decl.getSourceFile()) && inferredObjectType?.kind === "func") ||
(isJsSourceFile(decl.getSourceFile()) && inferredObjectType !== null &&
(inferredObjectType.kind === "func" || jsOpenObjectType(decl, inferredObjectType, lowerer.shapes, lowerer.unions).kind === "dyn")) ||
(isJsSourceFile(decl.getSourceFile()) && inferredObjectType !== null &&
(isUnitType(inferredObjectType) || inferredObjectType.kind === "union" && lowerer.unions.get(inferredObjectType.unionId)?.arms.every(isUnitType)))) &&
!decl.type && !hasJsTypeAnnotation(decl);
+6
View File
@@ -6333,6 +6333,8 @@ function canBoxDynComposite(
case "regex":
case "url":
return true;
case "object":
return isDynTypedRefType(t);
case "func":
return canBoxFuncIntoDyn(t, getRecord, getUnion);
case "set":
@@ -6386,6 +6388,10 @@ export function canDynCheckTo(
if (t.kind === "bytes") return true;
if (t.kind === "classval") return true;
if (t.kind === "object" && t.className === "%Error") return true;
// Native class arrays validate each branded reference, never fabricate
// class instances from plain object data.
if (isDynTypedRefType(t)) return true;
if (t.kind === "array" && isDynTypedRefType(t.elem)) return true;
if (t.kind === "func") return canAdaptDynFuncTo(t, getRecord, getUnion);
if (DYN_HANDLE_KINDS.has(t.kind)) return true;
if (t.kind === "union") {
@@ -0,0 +1,42 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile } from "../src/index.js";
test("checked class arrays reject unbranded and differently branded elements", async () => {
const dir = mkdtempSync("/tmp/scriptc-class-arrays-");
try {
const entry = join(dir, "main.ts");
writeFileSync(entry, `
class Item { value = 1; }
class Other { value = 1; }
function check(input: unknown): void {
try { const items = input as Item[]; console.log('accepted', items.length); }
catch (error) { console.log('rejected', error instanceof TypeError); }
}
check([new Item()]);
check([new Other()]);
check([{value: 1}]);
const mixed: unknown[] = [];
mixed.push(new Item()); mixed.push({value: 1});
check(mixed);
check(null);
check([1]);
check([]);
`);
const result = await compile(entry, {
dynamic: false, outDir: dir, outPath: join(dir, "program"),
sanitize: process.env["SCRIPTC_SAN"] === "1",
});
if (!result.ok) throw new Error(JSON.stringify(result.diagnostics));
const child = spawnSync(result.binaryPath, [], {encoding: "utf8", timeout: 30_000});
expect(child.error).toBeUndefined();
expect({status: child.status, signal: child.signal, stdout: child.stdout, stderr: child.stderr}).toEqual({
status: 0, signal: null, stderr: "",
stdout: "accepted 1\nrejected true\nrejected true\nrejected true\nrejected true\nrejected true\naccepted 0\n",
});
} finally {
rmSync(dir, {recursive: true, force: true});
}
});
@@ -11823,6 +11823,42 @@
"<repo>/tests/corpus/js-nested-native-descriptors.js"
],
"diags": []
},
"<repo>/tests/corpus/js-class-fluent-overrides.js": {
"order": [
"<repo>/tests/corpus/js-class-fluent-overrides.js"
],
"diags": []
},
"<repo>/tests/corpus/js-class-subclass-properties.js": {
"order": [
"<repo>/tests/corpus/js-class-subclass-properties.js"
],
"diags": []
},
"<repo>/tests/corpus/js-object-shared-arrays.js": {
"order": [
"<repo>/tests/corpus/js-object-shared-arrays.js"
],
"diags": []
},
"<repo>/tests/corpus/js-optional-class-fields.js": {
"order": [
"<repo>/tests/corpus/js-optional-class-fields.js"
],
"diags": []
},
"<repo>/tests/corpus/native-class-array-boundaries.ts": {
"order": [
"<repo>/tests/corpus/native-class-array-boundaries.ts"
],
"diags": []
},
"<repo>/tests/corpus/native-class-type-predicates.ts": {
"order": [
"<repo>/tests/corpus/native-class-type-predicates.ts"
],
"diags": []
}
}
}
+20 -5
View File
@@ -1951,6 +1951,9 @@ const ScrDynJsvalOps *scr_dyn_jsval_ops(void) {
bool scr_dyn_isl_typeof_is(const ScrDyn *d, const char *name) {
if (d->kind == SCR_DYN_TYPED_REF) {
if (d->v.typed_ref.type_key_len >= 7 &&
memcmp(d->v.typed_ref.type_key, "object:", 7) == 0)
return strcmp(name, "object") == 0;
ScrDyn *materialized = scr_dyn_typed_ref_materialize(d);
bool out = scr_dyn_isl_typeof_is(materialized, name);
if (materialized->kind != SCR_DYN_JSVAL) {
@@ -1978,6 +1981,8 @@ bool scr_dyn_isl_typeof_is(const ScrDyn *d, const char *name) {
bool scr_dyn_isl_is_array(const ScrDyn *d) {
if (d->kind == SCR_DYN_TYPED_REF) {
if (d->v.typed_ref.type_key_len >= 7 &&
memcmp(d->v.typed_ref.type_key, "object:", 7) == 0) return false;
ScrDyn *materialized = scr_dyn_typed_ref_materialize(d);
bool out = materialized->kind == SCR_DYN_ARR ||
scr_dyn_isl_is_array(materialized);
@@ -2425,6 +2430,9 @@ bool scr_dyn_truthy(const ScrDyn *d) {
ScrStr *scr_dyn_typeof(const ScrDyn *d) {
const char *s;
if (d->kind == SCR_DYN_TYPED_REF) {
if (d->v.typed_ref.type_key_len >= 7 &&
memcmp(d->v.typed_ref.type_key, "object:", 7) == 0)
return scr_str_new("object", 6);
ScrDyn *materialized = scr_dyn_typed_ref_materialize(d);
ScrStr *out = scr_dyn_typeof(materialized);
scr_dyn_release(materialized);
@@ -5963,11 +5971,18 @@ static bool scr_ffi_signature_equal(const ScrDyn *entry, const ScrDyn *definitio
if (!definition || definition->kind != SCR_DYN_OBJ) return false;
ScrDyn *expected = scr_dyn_obj_get(entry, "arguments", 9);
ScrDyn *actual = scr_dyn_obj_get(definition, "arguments", 9);
if (!actual || actual->kind != SCR_DYN_ARR || actual->v.arr.len != expected->v.arr.len ||
!scr_ffi_abi_equal(scr_dyn_obj_get(entry, "return", 6), scr_dyn_obj_get(definition, "return", 6))) return false;
for (size_t i = 0; i < actual->v.arr.len; i++)
if (!scr_ffi_abi_equal(actual->v.arr.items[i], expected->v.arr.items[i])) return false;
return true;
if (!actual) return false;
// JS object properties can retain a native array by reference. Read its
// current contents when checking the requested ABI, just like a dyn array.
ScrDyn *view = actual->kind == SCR_DYN_TYPED_REF
? scr_dyn_typed_ref_materialize(actual) : scr_dyn_retain(actual);
bool matches = view && view->kind == SCR_DYN_ARR && view->v.arr.len == expected->v.arr.len &&
scr_ffi_abi_equal(scr_dyn_obj_get(entry, "return", 6), scr_dyn_obj_get(definition, "return", 6));
if (matches) for (size_t i = 0; i < view->v.arr.len; i++) {
if (!scr_ffi_abi_equal(view->v.arr.items[i], expected->v.arr.items[i])) { matches = false; break; }
}
scr_dyn_release(view);
return matches;
}
static void scr_ffi_callback_remove(ScrDyn *entry, ScrDyn *catalog) {
+28
View File
@@ -0,0 +1,28 @@
class Base {
constructor() { this.value = 1; }
copy(source) { this.value = source.value; return this; }
dispatch(source) { return this.copy(source); }
}
class Child extends Base {
constructor() { super(); this.extra = 2; }
copy(source) { super.copy(source); this.extra = source.extra; return this; }
childOnly() { return this.extra; }
}
class Leaf extends Child {
constructor() { super(); this.leaf = 3; }
copy(source) {
super.copy(source);
if (source.leaf === 0) return (this);
this.leaf = source.leaf;
return this;
}
}
const source = new Leaf();
source.value = 4; source.extra = 5; source.leaf = 6;
const target = new Leaf();
console.log('direct', target.copy(source) === target, target.value, target.extra, target.leaf);
console.log('chain', target.copy(source).childOnly(), target.copy(source).leaf);
source.extra = 8;
console.log('virtual', target.dispatch(source) === target, target.extra);
source.leaf = 0;
console.log('early', target.copy(source) === target, target.leaf);
@@ -0,0 +1,43 @@
class Base {
constructor() { this.base = 1; }
read(key) { return this[key]; }
write(key, value) { this[key] = value; }
named() { return this.extra; }
attach(value) { this.extra = value; }
remember(key, value) {
if (this.cache === undefined) this.cache = {};
this.cache[key] = value;
}
recall(key) { return this.cache[key]; }
saved() {
const cache = this.cache;
if (cache === undefined) return 'empty';
return Object.keys(cache).join(',');
}
}
class Child extends Base {
constructor() { super(); this.extra = 2; this.storage = 3; }
get doubled() { return this.storage * 2; }
set doubled(value) { this.storage = value / 2; }
}
class Leaf extends Child {
constructor() { super(); this.leaf = 4; }
get doubled() { return this.storage * 3; }
set doubled(value) { this.storage = value / 3; }
}
const base = new Base(), child = new Child(), leaf = new Leaf();
console.log('absent', base.read('extra'), Object.keys(base).join(','));
base.attach({answer: 42});
console.log('added', base.read('extra').answer, Object.keys(base).join(','));
console.log('inherited', child.read('extra'), child.named(), leaf.read('leaf'));
child.write('extra', 9); leaf.write('leaf', 10);
child.write('doubled', 12); leaf.write('doubled', 12);
console.log('writes', child.extra, leaf.leaf, child.storage, leaf.storage);
console.log('accessors', child.read('doubled'), leaf.read('doubled'));
let order = '';
function receiver() { order += 'r'; return leaf; }
function key() { order += 'k'; return 'leaf'; }
console.log('read', receiver()[key()], order);
base.remember('first', 1); base.remember('second', {value: 2});
console.log('cache', base.recall('first'), base.recall('second').value, Object.keys(base.cache).join(','));
console.log('saved', base.saved(), child.saved());
+11
View File
@@ -0,0 +1,11 @@
const shared = [1, 2, 3];
const a = {values: shared}, b = {nested: {values: shared}};
console.log('identity', a.values === b.nested.values, a.values === shared);
a.values[1] = 8;
console.log('write', shared[1], b.nested.values[1]);
shared.push(4);
console.log('push', a.values.length, b.nested.values.join(','));
b.nested.values.pop();
console.log('pop', shared.length, a.values.join(','));
const separate = {values: [1, 8, 3]};
console.log('distinct', separate.values === a.values);
+26
View File
@@ -0,0 +1,26 @@
class Vector {
constructor() { this.x = 1; this.y = 2; }
get doubled() { return this.x * 2; }
set doubled(value) { this.x = value / 2; }
}
class Holder {
initialize() { this.vector = new Vector(); }
}
const holder = new Holder();
holder.initialize();
let calls = 0;
function receiver() { calls++; return holder; }
receiver().vector.x = 7;
holder.vector.y += 3;
holder.vector.doubled = 18;
console.log('nested', holder.vector.x, holder.vector.y, calls);
class Positioned {
constructor() { Object.defineProperty(this, 'position', {value: new Vector(), enumerable: true}); }
}
const objects = [new Positioned()];
console.log('array', objects[0].position.x);
objects[0].position.x = 4;
console.log('write', objects[0].position.x);
try { console.log(objects[1].position.x); }
catch (error) { console.log('missing', error instanceof TypeError, error.message); }
@@ -0,0 +1,29 @@
class Plane {
constant: number;
constructor(value: number) { this.constant = value; }
}
function roundtrip(value: unknown): Plane[] { return value as Plane[]; }
function optional(value: unknown): Plane[] | null { return value as Plane[] | null; }
const plane = new Plane(2);
const planes = [plane];
const copy = roundtrip(planes);
console.log('array', copy.length, copy[0] === plane, copy[0].constant);
copy[0].constant = 4;
console.log('mutation', plane.constant, optional(planes)![0] === plane, optional(null) === null);
function boxedRecord(value: unknown) { return value; }
const boxed = boxedRecord({plane, planes});
const view = boxed as {plane: unknown; planes: unknown[]};
console.log('record', view.plane === plane, view.planes[0] === plane);
const callback: unknown = (value: Plane): number => value.constant;
console.log('callback', (callback as (value: Plane) => number)(plane));
class Tree {
children: Tree[] = [];
select: (node: Tree) => Tree[] = (node) => node.children;
}
const root = new Tree(), child = new Tree();
root.children.push(child);
const tree: unknown = root;
const restored = tree as Tree;
console.log('recursive', restored === root, restored.select(root)[0] === child);
console.log('view', Object.keys(tree as object).sort().join(','));
@@ -0,0 +1,15 @@
class Opaque {
entries: Map<string, number> = new Map();
self: Opaque | null = null;
}
function inspect(value: unknown): void {
console.log(typeof value, typeof value === 'object', typeof value === 'function', Array.isArray(value));
}
const value = new Opaque();
value.self = value;
inspect(value);
inspect([value]);
inspect(null);
inspect(1);
inspect('text');
value.self = null;
+7 -1
View File
@@ -2,8 +2,9 @@ import { createRequire } from "node:module";
const requireModule = createRequire(import.meta.url);
const ffi = requireModule("node:ffi");
console.log(ffi === requireModule("node:ffi"));
const u64Arguments = ["u64"];
const definitions = {
nativeU64: { arguments: ["u64"], return: "u64" },
nativeU64: { arguments: u64Arguments, return: "u64" },
nativePointer: { arguments: ["pointer"], return: "pointer" },
nativeI64: { arguments: ["int64"], return: "int64" },
nativeByte: { arguments: ["bool"], return: "uint8" },
@@ -44,4 +45,9 @@ second.lib.close();
if (process.env.FFI_STATIC === "1") {
try { ffi.dlopen(process.env.FFI_LIBRARY, { nativeU64: { arguments: ["i64"], return: "u64" } }); }
catch { console.log("signature mismatch"); }
u64Arguments[0] = "i64";
try { ffi.dlopen(process.env.FFI_LIBRARY, definitions); }
catch { console.log("mutated signature"); }
try { ffi.dlopen(process.env.FFI_LIBRARY, { nativeU64: { return: "u64" } }); }
catch (error) { console.log("missing arguments", error.name, error.code); }
}
+45
View File
@@ -0,0 +1,45 @@
import { MeshNormalMaterial } from 'three/src/materials/MeshNormalMaterial.js';
import { MeshBasicMaterial } from 'three/src/materials/MeshBasicMaterial.js';
import { MeshStandardMaterial } from 'three/src/materials/MeshStandardMaterial.js';
import { Plane } from 'three/src/math/Plane.js';
import { Vector3 } from 'three/src/math/Vector3.js';
const normal = new MeshNormalMaterial({wireframe: true, opacity: 0.5, transparent: true});
console.log('normal', normal.type, normal.wireframe, normal.opacity, normal.transparent);
normal.setValues({wireframe: false, opacity: 0.8});
normal.needsUpdate = true;
console.log('updated', normal.wireframe, normal.opacity, normal.version);
const copy = normal.clone();
console.log('clone', copy !== normal, copy.type, copy.opacity, copy.normalScale.x);
copy.normalScale.x = 2;
console.log('independent', normal.normalScale.x, copy.normalScale.x);
const plane = new Plane(new Vector3(1, 0, 0), 2);
normal.setValues({clippingPlanes: [plane], clipIntersection: true});
if (normal.clippingPlanes !== null) {
console.log('clip', normal.clippingPlanes.length, normal.clippingPlanes[0] === plane, normal.clipIntersection);
normal.clippingPlanes[0].constant = 3;
}
console.log('plane', plane.constant);
normal.setValues({clippingPlanes: null});
console.log('unclipped', normal.clippingPlanes === null);
const basic = new MeshBasicMaterial({color: 0xff0000});
console.log('red', basic.color.r, basic.color.g, basic.color.b);
basic.setValues({color: 0x0000ff});
console.log('blue', basic.color.r, basic.color.g, basic.color.b);
console.log('basic-copy', basic.clone().color.b);
const standard = new MeshStandardMaterial({roughness: 0.25, metalness: 0.8, color: 0x00ff00});
console.log('standard', standard.type, standard.roughness, standard.metalness, standard.color.g);
console.log('standard-copy', standard.clone().roughness, standard.clone().metalness);
let count = 0;
function disposed(event) {
count++;
console.log('event', event.type, event.target === normal, this === normal);
}
normal.addEventListener('dispose', disposed);
normal.dispose();
normal.removeEventListener('dispose', disposed);
normal.dispose();
console.log('events', count);
+33
View File
@@ -0,0 +1,33 @@
import { Mesh } from 'three/src/objects/Mesh.js';
import { BoxGeometry } from 'three/src/geometries/BoxGeometry.js';
import { MeshNormalMaterial } from 'three/src/materials/MeshNormalMaterial.js';
import { Scene } from 'three/src/scenes/Scene.js';
import { Vector3 } from 'three/src/math/Vector3.js';
const geometry = new BoxGeometry(2, 4, 6);
const material = new MeshNormalMaterial();
const mesh = new Mesh(geometry, material);
console.log('mesh', mesh.type, mesh.isMesh, mesh.geometry === geometry, mesh.material === material);
const scene = new Scene();
scene.add(mesh);
mesh.position.set(1, 2, 3);
scene.updateMatrixWorld();
const point = mesh.getVertexPosition(0, new Vector3());
console.log('vertex', point.x, point.y, point.z);
console.log('world', mesh.matrixWorld.elements[12], mesh.matrixWorld.elements[13], mesh.matrixWorld.elements[14]);
const copy = mesh.clone();
console.log('copy', copy !== mesh, copy.geometry === geometry, copy.material === material, copy.position.x);
copy.position.x = 7;
console.log('independent', mesh.position.x, copy.position.x);
const other = new Mesh(geometry, material);
console.log('fluent', other.copy(mesh) === other, other.copy(mesh).geometry === geometry);
const child = new Mesh(geometry, material);
child.position.y = 3;
mesh.add(child);
const recursive = mesh.clone();
console.log('children', recursive.children.length, recursive.children[0] !== child, recursive.children[0].position.y);
console.log('shallow', mesh.clone(false).children.length);
const materials = [material, new MeshNormalMaterial({wireframe: true})];
const multi = new Mesh(geometry, materials);
const multiCopy = multi.clone();
console.log('materials', multiCopy.material !== multi.material, multiCopy.material.length, multiCopy.material[0] === material, multiCopy.material[1].wireframe);
+2 -2
View File
@@ -31,7 +31,7 @@ describe.each(["llvm"] as const)("static node:ffi catalog, %s", backend => {
expect(native.error).toBeUndefined();
expect(native.stderr).toBe("");
expect(native.status).toBe(0);
expect(native.stdout).toBe("true\n9007199254740993n\ntrue\n0n 0n\n65 255\n-9223372036854775808n 18446744073709551615n\n" + "TypeError ERR_INVALID_ARG_VALUE\n".repeat(12) + "5\narity\narity\nsignature\nclosed\n42n\nsignature mismatch\n");
expect(native.stdout).toBe("true\n9007199254740993n\ntrue\n0n 0n\n65 255\n-9223372036854775808n 18446744073709551615n\n" + "TypeError ERR_INVALID_ARG_VALUE\n".repeat(12) + "5\narity\narity\nsignature\nclosed\n42n\nsignature mismatch\nmutated signature\nmissing arguments Error SC2020\n");
// A separately linked Node library is only the oracle. scriptc links
// the object file and must work before this dynamic library exists.
if (oracle) {
@@ -42,7 +42,7 @@ describe.each(["llvm"] as const)("static node:ffi catalog, %s", backend => {
expect(node.error).toBeUndefined();
expect(node.stderr).toBe(native.stderr);
expect(node.status).toBe(native.status);
expect(node.stdout + "signature mismatch\n").toBe(native.stdout);
expect(node.stdout + "signature mismatch\nmutated signature\nmissing arguments Error SC2020\n").toBe(native.stdout);
}
});
});
+1 -1
View File
@@ -21,7 +21,7 @@ function run(command: string, args: string[]) {
return { stdout: result.stdout, stderr: result.stderr, status: result.status, signal: result.signal };
}
const cases = ["math", "spatial", "attributes", "geometry", "scene"].flatMap((fixture) =>
const cases = ["math", "spatial", "attributes", "geometry", "scene", "materials", "mesh"].flatMap((fixture) =>
["native", "wasm32-wasi"].map((target) => ({ fixture, target })));
test.for(cases)("published three.js $fixture runs statically through LLVM on $target", async ({ fixture, target }, context) => {
const entry = join(import.meta.dirname, `../fixtures/three/${fixture}.mjs`);