Created
May 4, 2026 14:14
-
-
Save kwhinnery/21ffc81310b3c80613b5f3cb0d1a417b to your computer and use it in GitHub Desktop.
Three.js X-Wing fighter, 30 minutes of prompting GPT-5.5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>1993 DOS-Style Low Poly X-Wing</title> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://cdn.jsdelivr.net/npm/three@0.164.1/build/three.module.js", | |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.164.1/examples/jsm/" | |
| } | |
| } | |
| </script> | |
| <style> | |
| html, body { | |
| margin: 0; | |
| width: 100%; | |
| height: 100%; | |
| overflow: hidden; | |
| background: #b8b8b0; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; | |
| color: #e8e8d8; | |
| image-rendering: pixelated; | |
| } | |
| #app { | |
| width: 100%; | |
| height: 100%; | |
| background: #b8b8b0; | |
| } | |
| .hud { | |
| position: fixed; | |
| left: 16px; | |
| top: 14px; | |
| z-index: 5; | |
| padding: 10px 12px; | |
| border: 1px solid #4f5f5f; | |
| background: rgba(0, 0, 0, 0.82); | |
| color: #d8d8c8; | |
| max-width: 390px; | |
| text-shadow: 1px 1px #000; | |
| } | |
| .hud h1 { | |
| margin: 0 0 6px; | |
| font-size: 14px; | |
| line-height: 1; | |
| color: #f0f0d8; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| } | |
| .hud p { | |
| margin: 0; | |
| font-size: 11px; | |
| line-height: 1.4; | |
| color: #aaa89a; | |
| } | |
| .tag-row { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| margin-top: 8px; | |
| align-items: center; | |
| } | |
| .tag, .status { | |
| display: inline-block; | |
| padding: 3px 6px; | |
| border: 1px solid #5f5f4f; | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| } | |
| .tag { | |
| color: #ff4a3d; | |
| background: #180000; | |
| } | |
| .status { | |
| color: #7cff9a; | |
| background: #001808; | |
| } | |
| .status.error { | |
| color: #ff6a5d; | |
| background: #180000; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"></div> | |
| <div class="hud"> | |
| <h1>X-WING POLYGON OBJECT</h1> | |
| <p>Flat shaded. Solid colors only. No textures. No PBR. Drag to orbit. Scroll to zoom.</p> | |
| <div class="tag-row"> | |
| <span class="tag">1993 DOS STYLE</span> | |
| <span id="test-status" class="status">Booting</span> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| const mount = document.getElementById('app'); | |
| const testStatus = document.getElementById('test-status'); | |
| function setTestStatus(message, isError = false) { | |
| testStatus.textContent = message; | |
| testStatus.classList.toggle('error', isError); | |
| } | |
| function assertSmokeTest(condition, message) { | |
| console.assert(condition, message); | |
| if (!condition) throw new Error(`Smoke test failed: ${message}`); | |
| } | |
| function nearlyEqual(a, b, epsilon = 0.0001) { | |
| return Math.abs(a - b) < epsilon; | |
| } | |
| try { | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0xb8b8b0); | |
| const camera = new THREE.PerspectiveCamera(44, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.set(8, 4.3, 11); | |
| const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false }); | |
| renderer.setPixelRatio(1); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.outputColorSpace = THREE.SRGBColorSpace; | |
| renderer.shadowMap.enabled = false; | |
| mount.appendChild(renderer.domElement); | |
| const controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| controls.target.set(0, 0.15, -0.2); | |
| controls.minDistance = 5; | |
| controls.maxDistance = 32; | |
| function makeFlatMat(color) { | |
| return new THREE.MeshLambertMaterial({ | |
| color, | |
| flatShading: true, | |
| dithering: false, | |
| side: THREE.DoubleSide | |
| }); | |
| } | |
| const matHull = makeFlatMat(0xc6c4b0); | |
| const matHullLight = makeFlatMat(0xd9d6c2); | |
| const matHullDark = makeFlatMat(0x77766a); | |
| const matRed = makeFlatMat(0xb51b16); | |
| const matBlue = makeFlatMat(0x203b87); | |
| const matBlack = makeFlatMat(0x050505); | |
| const matGray = makeFlatMat(0x6e6d62); | |
| const matStar = new THREE.PointsMaterial({ color: 0x5f5f58, size: 1.4, sizeAttenuation: false }); | |
| function addMesh(group, geometry, material, position = [0, 0, 0], rotation = [0, 0, 0], scale = [1, 1, 1]) { | |
| geometry.computeVertexNormals(); | |
| const mesh = new THREE.Mesh(geometry, material); | |
| mesh.position.set(...position); | |
| mesh.rotation.set(...rotation); | |
| mesh.scale.set(...scale); | |
| group.add(mesh); | |
| return mesh; | |
| } | |
| function makeWedge(length, height, widthFront, widthBack) { | |
| const l = length / 2; | |
| const h = height / 2; | |
| const wf = widthFront / 2; | |
| const wb = widthBack / 2; | |
| const vertices = new Float32Array([ | |
| -wf, -h, -l, wf, -h, -l, wf, h, -l, -wf, h, -l, | |
| -wb, -h, l, wb, -h, l, wb, h, l, -wb, h, l | |
| ]); | |
| const indices = [ | |
| 0, 1, 2, 0, 2, 3, | |
| 4, 6, 5, 4, 7, 6, | |
| 0, 4, 5, 0, 5, 1, | |
| 3, 2, 6, 3, 6, 7, | |
| 1, 5, 6, 1, 6, 2, | |
| 0, 3, 7, 0, 7, 4 | |
| ]; | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); | |
| geometry.setIndex(indices); | |
| return geometry; | |
| } | |
| function makeFlatNoseCone(length, radiusFront, radiusBack, sides = 8) { | |
| const l = length / 2; | |
| const vertices = []; | |
| for (let i = 0; i < sides; i++) { | |
| const a = (i / sides) * Math.PI * 2 + Math.PI / sides; | |
| vertices.push(Math.cos(a) * radiusFront, Math.sin(a) * radiusFront, -l); | |
| } | |
| for (let i = 0; i < sides; i++) { | |
| const a = (i / sides) * Math.PI * 2 + Math.PI / sides; | |
| vertices.push(Math.cos(a) * radiusBack, Math.sin(a) * radiusBack, l); | |
| } | |
| const indices = []; | |
| for (let i = 0; i < sides; i++) { | |
| const n = (i + 1) % sides; | |
| indices.push(i, n, sides + n, i, sides + n, sides + i); | |
| } | |
| for (let i = 1; i < sides - 1; i++) { | |
| indices.push(0, i + 1, i); | |
| indices.push(sides, sides + i, sides + i + 1); | |
| } | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vertices), 3)); | |
| geometry.setIndex(indices); | |
| return geometry; | |
| } | |
| function makeLowPolyTube(length, radiusFront, radiusBack, sides = 8) { | |
| const l = length / 2; | |
| const vertices = []; | |
| for (let i = 0; i < sides; i++) { | |
| const a = (i / sides) * Math.PI * 2 + Math.PI / sides; | |
| vertices.push(Math.cos(a) * radiusFront, Math.sin(a) * radiusFront, -l); | |
| } | |
| for (let i = 0; i < sides; i++) { | |
| const a = (i / sides) * Math.PI * 2 + Math.PI / sides; | |
| vertices.push(Math.cos(a) * radiusBack, Math.sin(a) * radiusBack, l); | |
| } | |
| const indices = []; | |
| for (let i = 0; i < sides; i++) { | |
| const n = (i + 1) % sides; | |
| indices.push(i, n, sides + n, i, sides + n, sides + i); | |
| } | |
| for (let i = 1; i < sides - 1; i++) { | |
| indices.push(0, i, i + 1); | |
| indices.push(sides, sides + i + 1, sides + i); | |
| } | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vertices), 3)); | |
| geometry.setIndex(indices); | |
| return geometry; | |
| } | |
| function makeTriangleWing(length, rootWidth, tipWidth, thickness) { | |
| const x0 = 0; | |
| const x1 = length; | |
| const zRootFront = -rootWidth / 2; | |
| const zRootBack = rootWidth / 2; | |
| const zTipFront = -tipWidth / 2; | |
| const zTipBack = tipWidth / 2; | |
| const yTop = thickness / 2; | |
| const yBot = -thickness / 2; | |
| const vertices = new Float32Array([ | |
| x0, yTop, zRootFront, x0, yTop, zRootBack, x1, yTop, zTipBack, x1, yTop, zTipFront, | |
| x0, yBot, zRootFront, x0, yBot, zRootBack, x1, yBot, zTipBack, x1, yBot, zTipFront | |
| ]); | |
| const indices = [ | |
| 0, 1, 2, 0, 2, 3, | |
| 4, 6, 5, 4, 7, 6, | |
| 0, 4, 5, 0, 5, 1, | |
| 1, 5, 6, 1, 6, 2, | |
| 2, 6, 7, 2, 7, 3, | |
| 3, 7, 4, 3, 4, 0 | |
| ]; | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); | |
| geometry.setIndex(indices); | |
| return geometry; | |
| } | |
| function makeSquaredHalfCylinderCanopyFront(length = 0.62, width = 0.72, height = 0.27) { | |
| const z0 = -length / 2; | |
| const z1 = length / 2; | |
| const halfWBack = width / 2; | |
| const halfWFront = halfWBack * 0.58; | |
| const sideHBack = height * 0.35; | |
| const sideHFront = sideHBack * 0.78; | |
| const topHBack = height; | |
| const topHFront = height * 0.72; | |
| const vertices = new Float32Array([ | |
| -halfWFront, 0, z0, | |
| halfWFront, 0, z0, | |
| halfWFront, sideHFront, z0, | |
| halfWFront * 0.42, topHFront, z0, | |
| -halfWFront * 0.42, topHFront, z0, | |
| -halfWFront, sideHFront, z0, | |
| -halfWBack, 0, z1, | |
| halfWBack, 0, z1, | |
| halfWBack, sideHBack, z1, | |
| halfWBack * 0.42, topHBack, z1, | |
| -halfWBack * 0.42, topHBack, z1, | |
| -halfWBack, sideHBack, z1 | |
| ]); | |
| const indices = [ | |
| 0, 1, 2, 0, 2, 5, 5, 2, 3, 5, 3, 4, | |
| 6, 8, 7, 6, 11, 8, 11, 9, 8, 11, 10, 9, | |
| 0, 6, 7, 0, 7, 1, | |
| 1, 7, 8, 1, 8, 2, | |
| 2, 8, 9, 2, 9, 3, | |
| 3, 9, 10, 3, 10, 4, | |
| 4, 10, 11, 4, 11, 5, | |
| 5, 11, 6, 5, 6, 0 | |
| ]; | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); | |
| geometry.setIndex(indices); | |
| return geometry; | |
| } | |
| function makeSquaredHalfCylinderCanopyBack(length = 0.42, width = 0.72, height = 0.27) { | |
| const z0 = -length / 2; | |
| const z1 = length / 2; | |
| const halfW = width / 2; | |
| const sideH = height * 0.35; | |
| const topH = height; | |
| const vertices = new Float32Array([ | |
| -halfW, 0, z0, | |
| halfW, 0, z0, | |
| halfW, sideH, z0, | |
| halfW * 0.42, topH, z0, | |
| -halfW * 0.42, topH, z0, | |
| -halfW, sideH, z0, | |
| -halfW, 0, z1, | |
| halfW, 0, z1, | |
| halfW, sideH, z1, | |
| halfW * 0.42, topH, z1, | |
| -halfW * 0.42, topH, z1, | |
| -halfW, sideH, z1 | |
| ]); | |
| const indices = [ | |
| 0, 1, 2, 0, 2, 5, 5, 2, 3, 5, 3, 4, | |
| 6, 8, 7, 6, 11, 8, 11, 9, 8, 11, 10, 9, | |
| 0, 6, 7, 0, 7, 1, | |
| 1, 7, 8, 1, 8, 2, | |
| 2, 8, 9, 2, 9, 3, | |
| 3, 9, 10, 3, 10, 4, | |
| 4, 10, 11, 4, 11, 5, | |
| 5, 11, 6, 5, 6, 0 | |
| ]; | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); | |
| geometry.setIndex(indices); | |
| return geometry; | |
| } | |
| function makeOctagonalPrismZ(radius, depth) { | |
| const zFront = -depth / 2; | |
| const zBack = depth / 2; | |
| const vertices = []; | |
| for (let i = 0; i < 8; i++) { | |
| const a = (i / 8) * Math.PI * 2 + Math.PI / 8; | |
| vertices.push(Math.cos(a) * radius, Math.sin(a) * radius, zFront); | |
| } | |
| for (let i = 0; i < 8; i++) { | |
| const a = (i / 8) * Math.PI * 2 + Math.PI / 8; | |
| vertices.push(Math.cos(a) * radius, Math.sin(a) * radius, zBack); | |
| } | |
| const indices = []; | |
| for (let i = 0; i < 8; i++) { | |
| const n = (i + 1) % 8; | |
| indices.push(i, n, 8 + n, i, 8 + n, 8 + i); | |
| } | |
| for (let i = 1; i < 7; i++) { | |
| indices.push(0, i, i + 1); | |
| indices.push(8, 8 + i + 1, 8 + i); | |
| } | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vertices), 3)); | |
| geometry.setIndex(indices); | |
| return geometry; | |
| } | |
| function createTubeFacetStripe({ sideX, zStart, zEnd, tubeCenterZ, tubeLength, radiusFront, radiusBack, scaleX, scaleY, name, insetA = 0.18, insetB = 0.82, lift = 0.018 }) { | |
| const tubeFrontZ = tubeCenterZ - tubeLength / 2; | |
| function radiusAt(z) { | |
| const t = THREE.MathUtils.clamp((z - tubeFrontZ) / tubeLength, 0, 1); | |
| return THREE.MathUtils.lerp(radiusFront, radiusBack, t); | |
| } | |
| const a0 = sideX > 0 ? -Math.PI / 8 : 7 * Math.PI / 8; | |
| const a1 = sideX > 0 ? Math.PI / 8 : 9 * Math.PI / 8; | |
| const normalAngle = sideX > 0 ? 0 : Math.PI; | |
| const normalLift = new THREE.Vector3(Math.cos(normalAngle) * lift, Math.sin(normalAngle) * lift, 0); | |
| function pointOnFacet(z, inset) { | |
| const r = radiusAt(z); | |
| const edgeA = new THREE.Vector3(Math.cos(a0) * r * scaleX, Math.sin(a0) * r * scaleY, z); | |
| const edgeB = new THREE.Vector3(Math.cos(a1) * r * scaleX, Math.sin(a1) * r * scaleY, z); | |
| return edgeA.lerp(edgeB, inset).add(normalLift); | |
| } | |
| const corners = [ | |
| pointOnFacet(zStart, insetA), | |
| pointOnFacet(zStart, insetB), | |
| pointOnFacet(zEnd, insetB), | |
| pointOnFacet(zEnd, insetA) | |
| ]; | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(corners.flatMap((p) => [p.x, p.y, p.z])), 3)); | |
| geometry.setIndex([0, 1, 2, 0, 2, 3]); | |
| const stripe = addMesh(fuselage, geometry, matRed); | |
| stripe.name = name; | |
| return stripe; | |
| } | |
| function createTubeTopStripe({ zStart, zEnd, tubeCenterZ, tubeCenterY = 0, tubeLength, radiusFront, radiusBack, scaleX, scaleY, name, insetA = 0.30, insetB = 0.70, lift = 0.01 }) { | |
| const tubeFrontZ = tubeCenterZ - tubeLength / 2; | |
| function radiusAt(z) { | |
| const t = THREE.MathUtils.clamp((z - tubeFrontZ) / tubeLength, 0, 1); | |
| return THREE.MathUtils.lerp(radiusFront, radiusBack, t); | |
| } | |
| const a0 = 3 * Math.PI / 8; | |
| const a1 = 5 * Math.PI / 8; | |
| const normalLift = new THREE.Vector3(0, lift, 0); | |
| function pointOnFacet(z, inset) { | |
| const r = radiusAt(z); | |
| const edgeA = new THREE.Vector3(Math.cos(a0) * r * scaleX, tubeCenterY + Math.sin(a0) * r * scaleY, z); | |
| const edgeB = new THREE.Vector3(Math.cos(a1) * r * scaleX, tubeCenterY + Math.sin(a1) * r * scaleY, z); | |
| return edgeA.lerp(edgeB, inset).add(normalLift); | |
| } | |
| const corners = [ | |
| pointOnFacet(zStart, insetA), | |
| pointOnFacet(zStart, insetB), | |
| pointOnFacet(zEnd, insetB), | |
| pointOnFacet(zEnd, insetA) | |
| ]; | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(corners.flatMap((p) => [p.x, p.y, p.z])), 3)); | |
| geometry.setIndex([0, 1, 2, 0, 2, 3]); | |
| const stripe = addMesh(fuselage, geometry, matRed); | |
| stripe.name = name; | |
| return stripe; | |
| } | |
| const root = new THREE.Group(); | |
| root.name = 'xwing_1993_low_poly_root'; | |
| root.rotation.y = Math.PI * 0.14; | |
| root.rotation.x = -Math.PI * 0.035; | |
| scene.add(root); | |
| const fuselage = new THREE.Group(); | |
| fuselage.name = 'fuselage'; | |
| root.add(fuselage); | |
| const mainFuselage = addMesh(fuselage, makeLowPolyTube(3.40, 0.56, 0.66, 8), matHull, [0, 0, -0.605], [0, 0, 0], [1.04, 0.86, 1]); | |
| mainFuselage.name = 'solid_main_fuselage_tube'; | |
| const forwardFuselage = addMesh(fuselage, makeLowPolyTube(3.55, 0.34, 0.48, 8), matHull, [0, -0.02, -3.72], [0, 0, 0], [1.04, 0.86, 1]); | |
| forwardFuselage.name = 'solid_forward_fuselage'; | |
| const noseCone = addMesh(fuselage, makeFlatNoseCone(0.86, 0.15, 0.49, 8), matHullDark, [0, -0.03, -5.93], [0, 0, 0], [1.04, 0.86, 1]); | |
| noseCone.name = 'solid_nose_cone'; | |
| const rearCapZ = -0.605 + 3.40 / 2; | |
| const rearPanelDepth = 0.18; | |
| const rearPanelZ = rearCapZ + rearPanelDepth / 2; | |
| const rearMachineryPanels = [ | |
| addMesh(fuselage, makeOctagonalPrismZ(0.38, rearPanelDepth), matHullDark, [0, 0, rearPanelZ], [0, 0, 0]) | |
| ]; | |
| rearMachineryPanels[0].name = 'rear_centered_octagon_machinery_panel'; | |
| const cockpitGlass = addMesh(fuselage, makeSquaredHalfCylinderCanopyFront(), matBlack, [0, 0.43, -1.42], [0, 0, 0]); | |
| cockpitGlass.name = 'squared_half_cylinder_cockpit_glass'; | |
| const cockpitRear = addMesh(fuselage, makeSquaredHalfCylinderCanopyBack(), matHullLight, [0, 0.43, -0.90], [0, 0, 0]); | |
| cockpitRear.name = 'solid_light_gray_cockpit_rear'; | |
| const astromechSocket = addMesh(fuselage, new THREE.CylinderGeometry(0.30, 0.30, 0.045, 8), matHullDark, [0, 0.49, 0.08], [0, 0, 0]); | |
| astromechSocket.name = 'astromech_socket'; | |
| const astromechBody = addMesh(fuselage, new THREE.CylinderGeometry(0.22, 0.22, 0.14, 8), matHullLight, [0, 0.56, 0.08], [0, 0, 0]); | |
| astromechBody.name = 'astromech_body'; | |
| const astromechDome = addMesh(fuselage, new THREE.SphereGeometry(0.22, 8, 4, 0, Math.PI * 2, 0, Math.PI / 2), matBlue, [0, 0.63, 0.08]); | |
| astromechDome.name = 'astromech_dome'; | |
| const fuselageSideStripes = [ | |
| createTubeFacetStripe({ sideX: 1, zStart: -4.15, zEnd: -2.05, tubeCenterZ: -3.72, tubeLength: 3.55, radiusFront: 0.34, radiusBack: 0.48, scaleX: 1.04, scaleY: 0.86, name: 'paint_stripe_forward_right' }), | |
| createTubeFacetStripe({ sideX: -1, zStart: -4.15, zEnd: -2.05, tubeCenterZ: -3.72, tubeLength: 3.55, radiusFront: 0.34, radiusBack: 0.48, scaleX: 1.04, scaleY: 0.86, name: 'paint_stripe_forward_left' }), | |
| createTubeFacetStripe({ sideX: 1, zStart: -2.05, zEnd: 0.70, tubeCenterZ: -0.605, tubeLength: 3.40, radiusFront: 0.56, radiusBack: 0.66, scaleX: 1.04, scaleY: 0.86, name: 'paint_stripe_rear_right', insetA: 0.18, insetB: 0.82, lift: 0.02 }), | |
| createTubeFacetStripe({ sideX: -1, zStart: -2.05, zEnd: 0.70, tubeCenterZ: -0.605, tubeLength: 3.40, radiusFront: 0.56, radiusBack: 0.66, scaleX: 1.04, scaleY: 0.86, name: 'paint_stripe_rear_left', insetA: 0.18, insetB: 0.82, lift: 0.02 }) | |
| ]; | |
| const forwardTopPaint = createTubeTopStripe({ zStart: -4.45, zEnd: -3.95, tubeCenterZ: -3.72, tubeCenterY: -0.02, tubeLength: 3.55, radiusFront: 0.34, radiusBack: 0.48, scaleX: 1.04, scaleY: 0.86, name: 'paint_panel_forward_top' }); | |
| addMesh(fuselage, new THREE.BoxGeometry(0.42, 0.04, 0.38, 1, 1, 1), matRed, [0, 0.46, 0.95]); | |
| const wings = []; | |
| const engines = []; | |
| const engineOuterNacelles = []; | |
| const cannons = []; | |
| const cannonSleeves = []; | |
| const wingShoulders = []; | |
| for (const sx of [-1, 1]) { | |
| const shoulder = addMesh(root, new THREE.BoxGeometry(0.72, 0.18, 0.92, 1, 1, 1), matHullDark, [sx * 0.62, 0.00, -0.18], [0, 0, sx * 0.08]); | |
| shoulder.name = `wing_shoulder_${sx > 0 ? 'right' : 'left'}`; | |
| wingShoulders.push(shoulder); | |
| } | |
| function createWing(sideX, sideY) { | |
| const hinge = new THREE.Group(); | |
| hinge.name = `hinge_${sideX > 0 ? 'right' : 'left'}_${sideY > 0 ? 'upper' : 'lower'}`; | |
| hinge.position.set(sideX * 0.32, sideY * 0.12, -0.06); | |
| hinge.rotation.z = sideX * sideY * 0.23; | |
| root.add(hinge); | |
| const wing = new THREE.Group(); | |
| wing.name = `wing_${sideX > 0 ? 'right' : 'left'}_${sideY > 0 ? 'upper' : 'lower'}`; | |
| hinge.add(wing); | |
| const wingMesh = addMesh(wing, makeTriangleWing(4.15, 1.72, 0.82, 0.08), matHull, [0, sideY * 0.02, 0.18], [0, 0, 0], [sideX, 1, 1]); | |
| wingMesh.name = `slab_${wing.name}`; | |
| addMesh(wing, new THREE.BoxGeometry(0.86, 0.09, 0.48, 1, 1, 1), matRed, [sideX * 3.05, sideY * 0.035, 0.02], [0, 0, 0]); | |
| const engine = new THREE.Group(); | |
| engine.name = `engine_${wing.name}`; | |
| engine.position.set(sideX * 0.48, sideY * 0.10, 0.80); | |
| wing.add(engine); | |
| const outerNacelle = addMesh(engine, new THREE.CylinderGeometry(0.312, 0.312, 1.219, 8), matGray, [0, 0, -0.90], [Math.PI / 2, 0, 0]); | |
| outerNacelle.name = `outer_nacelle_${engine.name}`; | |
| engineOuterNacelles.push(outerNacelle); | |
| addMesh(engine, new THREE.CylinderGeometry(0.198, 0.198, 1.742, 8), matGray, [0, 0, 0], [Math.PI / 2, 0, 0]); | |
| addMesh(engine, new THREE.CylinderGeometry(0.144, 0.144, 0.05, 8), matBlack, [0, 0, 0.896], [Math.PI / 2, 0, 0]); | |
| engines.push(engine); | |
| const cannon = new THREE.Group(); | |
| cannon.name = `laser_${wing.name}`; | |
| cannon.position.set(sideX * 4.06, sideY * 0.08, 0.18); | |
| wing.add(cannon); | |
| const rearSleeve = addMesh(cannon, new THREE.CylinderGeometry(0.082, 0.082, 1.04, 8), matGray, [0, 0, 0.00], [Math.PI / 2, 0, 0]); | |
| rearSleeve.name = `rear_sleeve_${cannon.name}`; | |
| cannonSleeves.push(rearSleeve); | |
| addMesh(cannon, new THREE.CylinderGeometry(0.032, 0.032, 2.7, 6), matHullLight, [0, 0, -1.05], [Math.PI / 2, 0, 0]); | |
| addMesh(cannon, new THREE.BoxGeometry(0.20, 0.20, 0.24, 1, 1, 1), matGray, [0, 0, -2.43]); | |
| addMesh(cannon, new THREE.ConeGeometry(0.13, 0.32, 4), matHullDark, [0, 0, -2.66], [Math.PI / 2, Math.PI / 4, 0]); | |
| cannons.push(cannon); | |
| wings.push(hinge); | |
| return hinge; | |
| } | |
| for (const sx of [-1, 1]) { | |
| for (const sy of [-1, 1]) createWing(sx, sy); | |
| } | |
| const starsGeo = new THREE.BufferGeometry(); | |
| const starCount = 180; | |
| const starPositions = new Float32Array(starCount * 3); | |
| for (let i = 0; i < starCount; i++) { | |
| starPositions[i * 3] = THREE.MathUtils.randFloatSpread(180); | |
| starPositions[i * 3 + 1] = THREE.MathUtils.randFloatSpread(95); | |
| starPositions[i * 3 + 2] = THREE.MathUtils.randFloat(-220, -35); | |
| } | |
| starsGeo.setAttribute('position', new THREE.BufferAttribute(starPositions, 3)); | |
| const stars = new THREE.Points(starsGeo, matStar); | |
| stars.name = 'static_dos_starfield'; | |
| scene.add(stars); | |
| scene.add(new THREE.AmbientLight(0x3f3f3f, 1.25)); | |
| const key = new THREE.DirectionalLight(0xffffff, 1.85); | |
| key.position.set(4, 6, 8); | |
| scene.add(key); | |
| const fill = new THREE.DirectionalLight(0x888866, 0.55); | |
| fill.position.set(-6, -3, -7); | |
| scene.add(fill); | |
| function runSmokeTests() { | |
| assertSmokeTest(typeof THREE.Scene === 'function', 'THREE module is loaded'); | |
| assertSmokeTest(typeof OrbitControls === 'function', 'OrbitControls module is loaded'); | |
| assertSmokeTest(renderer.domElement instanceof HTMLCanvasElement, 'renderer created a canvas'); | |
| assertSmokeTest(document.querySelectorAll('script[type="module"]').length === 1, 'document contains exactly one module script'); | |
| assertSmokeTest(root.children.includes(fuselage), 'fuselage is attached to the starfighter root'); | |
| assertSmokeTest(fuselage.getObjectByName('solid_main_fuselage_tube')?.geometry.index.count === 84, 'main fuselage is an eight-sided solid tube'); | |
| assertSmokeTest(fuselage.getObjectByName('solid_main_fuselage_tube')?.position.z === -0.605, 'main fuselage front seam stays fixed while rear cap is shortened'); | |
| assertSmokeTest(fuselage.getObjectByName('solid_forward_fuselage')?.geometry.index.count === 84, 'forward fuselage is an eight-sided solid tube'); | |
| assertSmokeTest(fuselage.getObjectByName('solid_nose_cone')?.geometry.index.count === 84, 'nose cone is an eight-sided flat-capped truncated cone'); | |
| assertSmokeTest(rearMachineryPanels.length === 1, 'one centered rear machinery surface panel was created'); | |
| assertSmokeTest(rearMachineryPanels[0].geometry.index.count === 84, 'rear machinery panel is an extruded octagonal prism'); | |
| assertSmokeTest(rearMachineryPanels[0].geometry.attributes.position.count === 16, 'rear machinery panel has eight front vertices and eight rear vertices'); | |
| assertSmokeTest(rearMachineryPanels[0].geometry.attributes.position.getX(0) > 0.35, 'rear machinery panel is about 75 percent of the fuselage rear diameter'); | |
| assertSmokeTest(Math.abs(rearMachineryPanels[0].geometry.attributes.position.getZ(8) - rearMachineryPanels[0].geometry.attributes.position.getZ(0) - rearPanelDepth) < 0.0001, 'rear machinery panel has shallow physical depth'); | |
| assertSmokeTest(rearMachineryPanels[0].position.x === 0 && rearMachineryPanels[0].position.y === 0, 'rear machinery panel is centered on the rear fuselage cap'); | |
| assertSmokeTest(Math.abs((rearMachineryPanels[0].position.z - rearPanelDepth / 2) - rearCapZ) < 0.0001, 'rear machinery panel front face is seated on the rear fuselage cap'); | |
| assertSmokeTest(rearMachineryPanels[0].rotation.x === 0 && rearMachineryPanels[0].rotation.y === 0 && rearMachineryPanels[0].rotation.z === 0, 'rear machinery panel protrudes straight back without angled mesh rotation'); | |
| assertSmokeTest(!fuselage.getObjectByName('cockpit_base_seated_on_fuselage'), 'cockpit base wedge has been removed'); | |
| assertSmokeTest(fuselage.getObjectByName('squared_half_cylinder_cockpit_glass')?.geometry.index.count === 60, 'cockpit glass uses a tapered front with the same rear cross-section as the rear section'); | |
| assertSmokeTest(Math.abs(fuselage.getObjectByName('squared_half_cylinder_cockpit_glass').geometry.attributes.position.getX(0)) < Math.abs(fuselage.getObjectByName('squared_half_cylinder_cockpit_glass').geometry.attributes.position.getX(6)), 'cockpit glass tapers narrower at the front while staying full width at the rear'); | |
| assertSmokeTest(fuselage.getObjectByName('squared_half_cylinder_cockpit_glass')?.position.z === -1.42, 'cockpit glass is positioned flush against the rear section'); | |
| assertSmokeTest(fuselage.getObjectByName('squared_half_cylinder_cockpit_glass')?.position.y === 0.43, 'cockpit glass sits close to the top of the fuselage'); | |
| assertSmokeTest(fuselage.getObjectByName('solid_light_gray_cockpit_rear')?.geometry.index.count === 60, 'cockpit has a solid light-gray squared half-cylinder rear section'); | |
| assertSmokeTest(fuselage.getObjectByName('solid_light_gray_cockpit_rear')?.position.y === 0.43, 'cockpit rear sits close to the top of the fuselage'); | |
| assertSmokeTest(Math.abs((fuselage.getObjectByName('squared_half_cylinder_cockpit_glass').position.z + 0.31) - (fuselage.getObjectByName('solid_light_gray_cockpit_rear').position.z - 0.21)) < 0.0001, 'cockpit glass rear edge lines up flush with solid rear front edge'); | |
| assertSmokeTest(fuselage.getObjectByName('astromech_socket'), 'astromech socket exists on the main fuselage'); | |
| assertSmokeTest(fuselage.getObjectByName('astromech_body')?.position.z === 0.08, 'astromech body is seated forward on the fuselage, not floating off the back'); | |
| assertSmokeTest(fuselage.getObjectByName('astromech_socket')?.position.y === 0.49, 'astromech socket is lowered onto the fuselage'); | |
| assertSmokeTest(fuselage.getObjectByName('astromech_body')?.position.y === 0.56, 'astromech body is lowered into its socket'); | |
| assertSmokeTest(fuselage.getObjectByName('astromech_dome')?.position.y < 0.66, 'astromech dome is low enough to sit in its socket'); | |
| assertSmokeTest(fuselageSideStripes.length === 4, 'four side paint stripe segments were created'); | |
| assertSmokeTest(fuselage.getObjectByName('paint_stripe_forward_right'), 'right forward paint stripe exists'); | |
| assertSmokeTest(fuselage.getObjectByName('paint_stripe_forward_left'), 'left forward paint stripe exists'); | |
| assertSmokeTest(fuselage.getObjectByName('paint_stripe_rear_right'), 'right rear paint stripe continuation exists'); | |
| assertSmokeTest(fuselage.getObjectByName('paint_stripe_rear_left'), 'left rear paint stripe continuation exists'); | |
| assertSmokeTest(fuselage.getObjectByName('paint_panel_forward_top'), 'forward top paint panel exists'); | |
| assertSmokeTest(forwardTopPaint.geometry.index.count === 6, 'forward top paint panel is a flat two-triangle surface panel'); | |
| assertSmokeTest(forwardTopPaint.geometry.attributes.position.getY(0) < 0.37, 'forward top paint panel is flush with the forward fuselage instead of floating above it'); | |
| assertSmokeTest(fuselageSideStripes.every((stripe) => stripe.geometry.index.count === 6), 'each side paint stripe is a flat two-triangle panel'); | |
| assertSmokeTest(fuselage.getObjectByName('paint_stripe_forward_right').geometry.attributes.position.getZ(2) === fuselage.getObjectByName('paint_stripe_rear_right').geometry.attributes.position.getZ(0), 'right forward and rear fuselage stripes touch continuously'); | |
| assertSmokeTest(fuselage.getObjectByName('paint_stripe_forward_left').geometry.attributes.position.getZ(2) === fuselage.getObjectByName('paint_stripe_rear_left').geometry.attributes.position.getZ(0), 'left forward and rear fuselage stripes touch continuously'); | |
| assertSmokeTest(wingShoulders.length === 2, 'two wing shoulder connector blocks were created'); | |
| assertSmokeTest(wings.length === 4, 'four transform-hinged S-foils were created'); | |
| assertSmokeTest(wings.every((hinge) => { | |
| const wing = hinge.children[0]; | |
| const slab = wing?.getObjectByName(`slab_${wing.name}`); | |
| const positions = slab?.geometry.attributes.position; | |
| if (!positions) return false; | |
| const rootChord = positions.getZ(1) - positions.getZ(0); | |
| const tipChord = positions.getZ(2) - positions.getZ(3); | |
| return nearlyEqual(rootChord, 1.72) && nearlyEqual(tipChord, 0.82); | |
| }), 'wing root and tip chords are broadened by about 50 percent'); | |
| assertSmokeTest(Math.abs(wings[0].position.x) < 0.4, 'wing hinges sit close enough to overlap the fuselage'); | |
| assertSmokeTest(wings.every((hinge) => nearlyEqual(hinge.position.z, -0.06)), 'wing assemblies are scooted forward to match the shortened rear fuselage'); | |
| assertSmokeTest(wingShoulders.every((shoulder) => nearlyEqual(shoulder.position.z, -0.18)), 'wing shoulder blocks move forward with the wing assemblies'); | |
| assertSmokeTest(engines.length === 4, 'four faceted engines were created'); | |
| assertSmokeTest(engines.every((engine) => nearlyEqual(Math.abs(engine.position.x), 0.48)), 'inner and outer engine assemblies are moved outward from the fuselage together'); | |
| assertSmokeTest(engines.every((engine) => { | |
| const body = engine.children.find((child) => child.geometry?.parameters?.height === 1.742); | |
| return body && nearlyEqual(body.geometry.parameters.radiusTop, 0.198) && nearlyEqual(body.geometry.parameters.radiusBottom, 0.198); | |
| }), 'inner engine cylinders remain about 40 percent smaller in diameter and 30 percent longer'); | |
| assertSmokeTest(engineOuterNacelles.length === 4, 'four wider outer engine nacelles were created'); | |
| assertSmokeTest(engineOuterNacelles.every((nacelle) => nearlyEqual(nacelle.geometry.parameters.radiusTop, 0.312) && nearlyEqual(nacelle.geometry.parameters.radiusBottom, 0.312) && nearlyEqual(nacelle.geometry.parameters.height, 1.219)), 'outer engine nacelles are 30 percent smaller in diameter and remain 70 percent as long as the inner cylinders'); | |
| assertSmokeTest(engineOuterNacelles.every((nacelle) => nearlyEqual(nacelle.position.z, -0.90)), 'outer engine nacelles are moved forward on the wings'); | |
| assertSmokeTest(engines.every((engine) => { | |
| const nacelle = engine.children.find((child) => child.name.startsWith('outer_nacelle_')); | |
| if (!nacelle) return false; | |
| const wingLocalFront = engine.position.z + nacelle.position.z - nacelle.geometry.parameters.height / 2; | |
| return wingLocalFront < -0.68; | |
| }), 'front of each outer nacelle overlaps the front edge of the broadened wing root'); | |
| assertSmokeTest(cannons.length === 4, 'four laser cannons were created'); | |
| assertSmokeTest(cannonSleeves.length === 4, 'four rear laser cannon sleeves were created'); | |
| assertSmokeTest(cannonSleeves.every((sleeve) => sleeve.geometry.parameters.radialSegments === 8), 'rear laser cannon sleeves are low-poly thin cylinders'); | |
| assertSmokeTest(cannonSleeves.every((sleeve) => nearlyEqual(sleeve.geometry.parameters.height, 1.04)), 'rear laser cannon sleeves span the widened wing tips'); | |
| assertSmokeTest(cannons.every((cannon) => Math.abs(cannon.position.x) < 4.15), 'laser cannon mounts overlap the wing tips instead of floating beyond them'); | |
| assertSmokeTest(cannons.every((cannon) => nearlyEqual(cannon.position.z, 0.18)), 'laser cannon hardpoints are centered on the broadened wing tip chord'); | |
| assertSmokeTest(stars.geometry.attributes.position.count === starCount, 'starfield has the expected number of stars'); | |
| assertSmokeTest(renderer.getPixelRatio() === 1, 'pixel ratio is locked to 1 for a retro jagged render'); | |
| assertSmokeTest(!renderer.shadowMap.enabled, 'shadows are disabled for flat retro rendering'); | |
| setTestStatus('Tests passed'); | |
| } | |
| runSmokeTests(); | |
| const clock = new THREE.Clock(); | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const t = clock.getElapsedTime(); | |
| root.rotation.y = Math.PI * 0.14 + Math.sin(t * 0.22) * 0.08; | |
| root.position.y = Math.sin(t * 0.55) * 0.045; | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| } | |
| animate(); | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| } catch (error) { | |
| console.error(error); | |
| setTestStatus('Error', true); | |
| const message = document.createElement('pre'); | |
| message.textContent = error instanceof Error ? error.message : String(error); | |
| message.style.position = 'fixed'; | |
| message.style.left = '16px'; | |
| message.style.bottom = '16px'; | |
| message.style.maxWidth = 'min(720px, calc(100vw - 32px))'; | |
| message.style.whiteSpace = 'pre-wrap'; | |
| message.style.padding = '10px 12px'; | |
| message.style.border = '1px solid #ff4a3d'; | |
| message.style.background = '#180000'; | |
| message.style.color = '#ffd0cc'; | |
| message.style.zIndex = '10'; | |
| document.body.appendChild(message); | |
| } | |
| </script> | |
| </body> | |
| engine.position.set(sideX * 0.48, sideY * 0.10, 0.80); | |
| wing.add(engine); | |
| // Larger outer nacelle: same color, 50% wider diameter, 70% as long. | |
| // It overlaps the existing inner engine cylinder to create a simple multi-part engine look. | |
| const outerNacelle = addMesh(engine, new THREE.CylinderGeometry(0.312, 0.312, 1.219, 8), matGray, [0, 0, -0.90], [Math.PI / 2, 0, 0]); | |
| outerNacelle.name = `outer_nacelle_${engine.name}`; | |
| engineOuterNacelles.push(outerNacelle); | |
| addMesh(engine, new THREE.CylinderGeometry(0.198, 0.198, 1.742, 8), matGray, [0, 0, 0], [Math.PI / 2, 0, 0]); | |
| addMesh(engine, new THREE.CylinderGeometry(0.144, 0.144, 0.05, 8), matBlack, [0, 0, 0.896], [Math.PI / 2, 0, 0]); | |
| engines.push(engine); | |
| // Extremely simple laser cannon: line-like 6-sided cylinder plus chunky tip. | |
| const cannon = new THREE.Group(); | |
| cannon.name = `laser_${wing.name}`; | |
| // Mount slightly inside the wing tip and centered on the widened wing's tip chord. | |
| // After broadening the wings, the old Z hardpoint was too far forward. | |
| cannon.position.set(sideX * 4.06, sideY * 0.08, 0.18); | |
| wing.add(cannon); | |
| const rearSleeve = addMesh(cannon, new THREE.CylinderGeometry(0.082, 0.082, 1.04, 8), matGray, [0, 0, 0.00], [Math.PI / 2, 0, 0]); | |
| rearSleeve.name = `rear_sleeve_${cannon.name}`; | |
| cannonSleeves.push(rearSleeve); | |
| addMesh(cannon, new THREE.CylinderGeometry(0.032, 0.032, 2.7, 6), matHullLight, [0, 0, -1.05], [Math.PI / 2, 0, 0]); | |
| addMesh(cannon, new THREE.BoxGeometry(0.20, 0.20, 0.24, 1, 1, 1), matGray, [0, 0, -2.43]); | |
| addMesh(cannon, new THREE.ConeGeometry(0.13, 0.32, 4), matHullDark, [0, 0, -2.66], [Math.PI / 2, Math.PI / 4, 0]); | |
| cannons.push(cannon); | |
| wings.push(hinge); | |
| return hinge; | |
| } | |
| for (const sx of [-1, 1]) { | |
| for (const sy of [-1, 1]) { | |
| createWing(sx, sy); | |
| } | |
| } | |
| // Sparse pixel-like starfield. | |
| const starsGeo = new THREE.BufferGeometry(); | |
| const starCount = 180; | |
| const starPositions = new Float32Array(starCount * 3); | |
| for (let i = 0; i < starCount; i++) { | |
| starPositions[i * 3] = THREE.MathUtils.randFloatSpread(180); | |
| starPositions[i * 3 + 1] = THREE.MathUtils.randFloatSpread(95); | |
| starPositions[i * 3 + 2] = THREE.MathUtils.randFloat(-220, -35); | |
| } | |
| starsGeo.setAttribute('position', new THREE.BufferAttribute(starPositions, 3)); | |
| const stars = new THREE.Points(starsGeo, matStar); | |
| stars.name = 'static_dos_starfield'; | |
| scene.add(stars); | |
| // Minimal fake DOS-era lighting. No shadows, no environment maps, no glow. | |
| scene.add(new THREE.AmbientLight(0x3f3f3f, 1.25)); | |
| const key = new THREE.DirectionalLight(0xffffff, 1.85); | |
| key.position.set(4, 6, 8); | |
| scene.add(key); | |
| const fill = new THREE.DirectionalLight(0x888866, 0.55); | |
| fill.position.set(-6, -3, -7); | |
| scene.add(fill); | |
| function runSmokeTests() { | |
| assertSmokeTest(typeof THREE.Scene === 'function', 'THREE module is loaded'); | |
| assertSmokeTest(typeof OrbitControls === 'function', 'OrbitControls module is loaded'); | |
| assertSmokeTest(renderer.domElement instanceof HTMLCanvasElement, 'renderer created a canvas'); | |
| assertSmokeTest(root.children.includes(fuselage), 'fuselage is attached to the starfighter root'); | |
| assertSmokeTest(fuselage.getObjectByName('solid_main_fuselage_tube')?.geometry.index.count === 84, 'main fuselage is an eight-sided solid tube'); | |
| assertSmokeTest(fuselage.getObjectByName('solid_main_fuselage_tube')?.position.z === -0.605, 'main fuselage front seam stays fixed while rear cap is shortened'); | |
| assertSmokeTest(fuselage.getObjectByName('solid_forward_fuselage')?.geometry.index.count === 84, 'forward fuselage is an eight-sided solid tube'); | |
| assertSmokeTest(fuselage.getObjectByName('solid_nose_cone')?.geometry.index.count === 84, 'nose cone is an eight-sided flat-capped truncated cone'); | |
| assertSmokeTest(rearMachineryPanels.length === 1, 'one centered rear machinery surface panel was created'); | |
| assertSmokeTest(rearMachineryPanels[0].geometry.index.count === 84, 'rear machinery panel is an extruded octagonal prism'); | |
| assertSmokeTest(rearMachineryPanels[0].geometry.attributes.position.count === 16, 'rear machinery panel has eight front vertices and eight rear vertices'); | |
| assertSmokeTest(rearMachineryPanels[0].geometry.attributes.position.getX(0) > 0.35, 'rear machinery panel is about 75 percent of the fuselage rear diameter'); | |
| assertSmokeTest(Math.abs(rearMachineryPanels[0].geometry.attributes.position.getZ(8) - rearMachineryPanels[0].geometry.attributes.position.getZ(0) - rearPanelDepth) < 0.0001, 'rear machinery panel has shallow physical depth'); | |
| assertSmokeTest(rearMachineryPanels[0].position.x === 0 && rearMachineryPanels[0].position.y === 0, 'rear machinery panel is centered on the rear fuselage cap'); | |
| assertSmokeTest(Math.abs((rearMa |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment